diff --git a/.docker/.gitkeep b/.docker/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/workflows/server.yaml b/.github/workflows/server.yaml index e4acbf3..53c53a6 100644 --- a/.github/workflows/server.yaml +++ b/.github/workflows/server.yaml @@ -5,6 +5,7 @@ on: tags-ignore: ["v*"] paths: - "server/**" + - "server-new/**" - "web/**" - Dockerfile pull_request: @@ -12,7 +13,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - RUST_VERSION: "1.90" + RUST_VERSION: "1.96" NODE_VERSION: 22 jobs: @@ -23,14 +24,14 @@ jobs: run: working-directory: ./web steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 name: Install pnpm with: package_json_file: web/package.json run_install: false - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: "pnpm" @@ -46,35 +47,25 @@ jobs: run: pnpm build build-server: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - name: Build server on ${{ matrix.os }} - runs-on: ${{ matrix.os }} + name: Build server + runs-on: ubuntu-latest defaults: run: - working-directory: ./server + working-directory: ./server-new steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain run: rustup toolchain install ${{ env.RUST_VERSION }} --profile minimal --no-self-update && rustup default ${{ env.RUST_VERSION }} - - name: Setup build dependencies on macOS - if: startsWith(runner.os, 'macOS') - run: brew link --force libpq - name: Setup rust-cache uses: Swatinem/rust-cache@v2 with: - workspaces: ./server - # - name: Install nextest - # uses: taiki-e/install-action@v2 - # with: - # tool: nextest@0.9 + workspaces: ./server-new - name: Run cargo check - run: cargo check --profile ci + run: cargo check - name: Run cargo build - run: PQ_LIB_DIR="$(brew --prefix libpq)/lib" cargo build --profile ci + run: cargo build # - name: Test crate # run: cargo nextest run --all-features --profile ci diff --git a/.gitignore b/.gitignore index 2391a64..32dd7e0 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ Desktop.ini *.pfx secrets.json config/secrets.yml +.docker/ .secrets/ # Dependency Directories diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a9dddf5..0f6bcd5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,14 +29,10 @@ RsChat uses a hybrid streaming architecture that provides both real-time perform ### Key Components -#### 1. LlmStreamWriter (`server/src/stream/llm_writer.rs`) +#### 1. LlmClientStreamer (`server/src/stream/streamer.rs`) The core component that processes LLM provider streams and manages Redis stream output. -**Key Features:** -- **Batching**: Accumulates chunks from the provider stream, up to a max length or timeout, and adds them to the Redis stream -- **Background Pings**: Sends regular keepalive pings - #### 2. Redis and SSE Stream Structure **Redis Key for Chat Streams**: `user:{user_id}:chat:{session_id}` @@ -68,8 +64,7 @@ Stream End → Database Save → Redis DEL #### Cross-Instance Support - Redis streams provide shared state across server instances -- Background ping tasks maintain stream liveness -- Stream cancellation detected via Redis XADD failures +- Stream cancellation detection ## Data Flow @@ -77,23 +72,21 @@ Stream End → Database Save → Redis DEL ``` Client → POST /api/chat/{session_id} → Send request to LLM Provider - → LLM response received, streamed to Redis with the `LlmStreamWriter` - → GET /api/chat/{session_id}/stream to connect to the stream and stream the response + → LLM response received, streamed to Redis with the `LlmClientStreamer` ``` ### 2. Stream Processing ``` LLM Chunk → Process text, tool calls, usage, and error chunks → Batching Logic - → Redis XADD (if conditions met) - → Client(s) receive the new chunks + → Add chunks to Redis via `tinistream` service + → Client(s) receive the new chunks from `tinistream` ``` ### 3. Stream Completion ``` LLM End → Final Database Save - → Redis Stream End Event - → Redis Stream Cleanup + → `tinistream` End Event → SSE Connection Close ``` diff --git a/README.md b/README.md index ecd340c..928e5b4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RsChat 🤖💬 -A fast, secure, self-hostable chat application built with Rust, TypeScript, and React. Chat with multiple AI providers using your own API keys, with real-time streaming built-in. +A lightweight, secure, open-source, self-hostable chat application built with Rust, TypeScript, and React. Stream chats with multiple AI providers using your own API keys. Demo link: https://rs-chat.fly.dev/ (⚠️ This is a demo - don't expect your account/chats to be there when you come back. It may intermittently delete all data. Please also don't enter any sensitive information or confidential data) @@ -11,21 +11,20 @@ Demo link: https://rs-chat.fly.dev/ (⚠️ This is a demo - don't expect your a - **Multiple AI Providers**: Chat with AI models from OpenAI, Anthropic, and OpenRouter - **Streaming**: Streams responses using SSE (Server-Sent Events) - **Concurrent Streaming**: Seamlessly switch between multiple AI conversations streamed at the same time -- **Resumable Conversations**: Resume the conversation if your connection is lost or the page is refreshed +- **Resilient Streams**: Streaming continues if your connection is lost or the page is refreshed - **Code Highlighting**: Beautiful syntax highlighting for code blocks using [`rehype-highlight`](https://github.com/rehypejs/rehype-highlight) - **Dark Mode**: Dark/light theme support - **Responsive Design**: Mobile-friendly layout - **Search Chats**: Full-text search of chat session titles and messages - **Fast and Memory Efficient**: Rust backend using the [Rocket framework](https://rocket.rs/) - **Users & Authentication**: Login via OAuth providers (Google, GitHub, etc.), custom OIDC, and SSO header authentication -- **API Key Access and OpenAPI Docs**: API key access and documentation at `/api/docs` for developers to integrate with RsChat +- **Documented API**: API key access and documentation at `/api/docs` for developers to integrate with RsChat - **Fully Type-Safe**: End-to-end type safety with auto-generated client from OpenAPI spec ### ⚡ Convenience Features - **Smart Titles**: Auto-generation of chat titles -- **Smart Scrolling**: Auto-scroll during streaming and when opening previous chats -- **Secure Key Storage**: Your API keys are saved and encrypted +- **Auto Scrolling**: Auto-scroll during streaming and when opening previous chats ## 🏗️ Architecture @@ -45,14 +44,17 @@ rs-chat/ │ │ ├── auth/ # Authentication services │ │ ├── db/ # Database models and services │ │ ├── provider/ # AI provider integrations -│ │ ├── utils/ # Utility functions -│ │ ├── config.rs # Reading configuration / env variables +│ │ ├── storage/ # File storage services +│ │ ├── stream/ # Streaming utilities +│ │ ├── tools/ # AI chat tools +│ │ ├── utils/ # Other utilities +│ │ ├── config.rs # Configuration / environment variables │ │ ├── lib.rs # Server setup │ │ ├── main.rs # Server entry point │ │ └── ... # Other modules │ ├── migrations/ # Database migrations │ └── Cargo.toml # Rust dependencies -├── web/ # Vite / React frontend +├── web/ # Vite / React / TanStack Router frontend │ ├── src/ │ │ ├── components/ # React components │ │ ├── routes/ # TanStack Router routes @@ -90,9 +92,9 @@ Your API keys are encrypted and stored in the database. cd rs-chat ``` -2. **Start development databases** +2. **Start development services** ```bash - docker compose up -d db redis + docker compose up -d db redis stream ``` 3. **Set up the backend** @@ -145,33 +147,47 @@ You'll need an environment with PostgreSQL and Redis (or Redis-compatible databa services: rschat: image: ghcr.io/fa-sharp/rs-chat:latest - # ports: - # - "8080:8080" + ports: + - "8080:8080" environment: RUST_LOG: warn # 'info' or 'debug' for more logs RS_CHAT_SERVER_ADDRESS: https://mydomain.com # where you're hosting the app RS_CHAT_DATABASE_URL: postgres://user:pass@mypostgres/mydb # Your PostgreSQL URL RS_CHAT_REDIS_URL: redis://myredis:6379 # Your Redis URL RS_CHAT_SECRET_KEY: your-secret-key-for-encryption # 64-character hex string + RS_CHAT_TINISTREAM_URL: http://tinistream:8081 + RS_CHAT_TINISTREAM_API_KEY: tinistream-api-key # API key for the tinistream service + ## For GitHub login: callback URL should be {your_server_address}/api/auth/login/github/callback # RS_CHAT_GITHUB_CLIENT_ID: your-github-client-id # RS_CHAT_GITHUB_CLIENT_SECRET: your-github-client-secret ## Similar config for other OAuth providers - see server/src/auth/oauth/ folder # RS_CHAT_DISCORD_CLIENT_ID: your-discord-client-id # ... + ## For SSO header auth - see server/src/auth/sso_header.rs for all config options # RS_CHAT_SSO_HEADER_ENABLED: true # RS_CHAT_SSO_USERNAME_HEADER: X-Remote-User # ... + ## For running code on a remote Docker host # DOCKER_HOST: tcp://remote-docker-host:port # DOCKER_TLS_VERIFY: 1 # DOCKER_CERT_PATH: /certs volumes: - ## For running code on local Docker host - # - /var/run/docker.sock:/var/run/docker.sock:ro - ## Certificates for remote Docker host - # - ./path/to/certs:/certs + # - /var/run/docker.sock:/var/run/docker.sock:ro # To run code on local Docker + # - ./path/to/certs:/certs # Certificates for remote Docker host + tinistream: + image: ghcr.io/fa-sharp/tinistream:latest + container_name: tinistream + ports: + - "8081:8081" + environment: + STREAMER_PORT: 8081 + STREAMER_SERVER_ADDRESS: http://localhost:8081 + STREAMER_REDIS_URL: redis://myredis:6379 # Your Redis URL + STREAMER_API_KEY: tinistream-api-key # should match the RS_CHAT_TINISTREAM_API_KEY above + STREAMER_SECRET_KEY: your-secret-key-for-encryption # 64-character hex string ``` ## 🔒 Security & Privacy diff --git a/docker-compose.yml b/docker-compose.yml index 285da90..1be9823 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - postgres_data:/var/lib/postgresql/data redis: - image: valkey/valkey:7-alpine + image: valkey/valkey:8-alpine container_name: redis ports: - "6379:6379" @@ -20,40 +20,57 @@ services: - redis_data:/data stream: - image: ghcr.io/fa-sharp/tinistream:0.1.9 - platform: linux/amd64 + image: tinistream container_name: tinistream ports: - "8081:8081" environment: STREAMER_PORT: 8081 - STREAMER_SERVER_ADDRESS: http://localhost:8081 + STREAMER_API_KEY: dev-streamer-api-key + STREAMER_BASE_URL: http://localhost:8081 STREAMER_REDIS_URL: redis://redis:6379 - STREAMER_TTL: 360 - env_file: server/.env + STREAMER_KEY_PREFIX: "rs-chat:" + env_file: server-new/.env depends_on: - redis - rschat: - build: - context: . - ports: - - "8080:8080" - environment: - RUST_LOG: info - RS_CHAT_SERVER_ADDRESS: http://localhost:8080 - RS_CHAT_DATABASE_URL: postgres://postgres:postgres@postgres/postgres - RS_CHAT_REDIS_URL: redis://redis:6379 - RS_CHAT_DATA_DIR: /data - RS_CHAT_TINISTREAM_URL: http://tinistream:8081 - env_file: server/.env - volumes: - - ./.docker:/certs - - rschat_data:/data - depends_on: - - db - - redis - - stream + # runner: + # image: ghcr.io/fa-sharp/tinirun:0.1.1 + # container_name: tinirun + # ports: + # - "8082:8082" + # environment: + # RUNNER_HOST: 0.0.0.0 + # RUNNER_PORT: 8082 + # RUNNER_LOG_LEVEL: info + # RUNNER_REDIS_URL: redis://redis:6379 + # env_file: server/.env + # depends_on: + # - redis + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock + + # rschat: + # build: + # context: . + # ports: + # - "8080:8080" + # environment: + # RUST_LOG: info + # RS_CHAT_SERVER_ADDRESS: http://localhost:8080 + # RS_CHAT_DATABASE_URL: postgres://postgres:postgres@postgres/postgres + # RS_CHAT_REDIS_URL: redis://redis:6379 + # RS_CHAT_DATA_DIR: /data + # RS_CHAT_TINISTREAM_URL: http://tinistream:8081 + # RS_CHAT_TINIRUN_URL: http://tinirun:8082 + # env_file: server/.env + # volumes: + # - rschat_data:/data + # depends_on: + # - db + # - redis + # - stream + # - runner volumes: postgres_data: diff --git a/examples/compose/compose.yml b/examples/compose/compose.yml new file mode 100644 index 0000000..4640904 --- /dev/null +++ b/examples/compose/compose.yml @@ -0,0 +1 @@ +# TODO diff --git a/examples/compose/config.toml b/examples/compose/config.toml new file mode 100644 index 0000000..2509736 --- /dev/null +++ b/examples/compose/config.toml @@ -0,0 +1,40 @@ +[server] +host = "0.0.0.0" +port = 8080 +base_url = "https://example.com" # set to domain & path where you're hosting +log_level = "info" +request_id_header = "x-request-id" + +[database] +url = "postgres://user:pass@mydb:5432" + +[redis] +url = "redis://pass@myredis:6379" +pool_size = 4 +timeout = 10 + +[services] +streamer_url = "http://tinistream:8081" +streamer_api_key = "tinistream-api-key" + +[auth] +cookie_name = "auth-rs-chat" +session_length = 604800 # seconds + +# Configure GitHub / Discord / Google login +[auth.github] +client_id = "" +client_secret = "" + +# Configure forward header authentication +# ⚠️ Only use with a secure proxy like Authelia or Tinyauth +[auth.proxy] +enabled = false +username_header = "Remote-User" +name_header = "Remote-Name" +groups_header = "Remote-Groups" + +# Configure various security settings +[security] +body_limit = 2097152 # bytes +request_timeout = 120 # seconds diff --git a/server-new/.env.example b/server-new/.env.example new file mode 100644 index 0000000..3120ed4 --- /dev/null +++ b/server-new/.env.example @@ -0,0 +1,10 @@ +# Database (needed for Diesel CLI) +DATABASE_URL=postgres://postgres:postgres@localhost/postgres + +# Auth +RS_CHAT_AUTH__ENCRYPTION_KEY= # 32-byte hex secret, e.g. `openssl rand --hex 32` +RS_CHAT_AUTH__GITHUB__CLIENT_ID= +RS_CHAT_AUTH__GITHUB__CLIENT_SECRET= + +# Logging +RS_CHAT_SERVER__LOG_LEVEL=info diff --git a/server-new/.gitignore b/server-new/.gitignore new file mode 100644 index 0000000..a0c632a --- /dev/null +++ b/server-new/.gitignore @@ -0,0 +1,24 @@ +# Local storage files +.local/ + +# Rust +/target +.diesel_lock + +# Env files +.env* +!.env.example + +# OS files +.DS_Store + +# Build artifacts +*.exe +*.dll +*.so +*.dylib + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/server-new/Cargo.lock b/server-new/Cargo.lock new file mode 100644 index 0000000..29f06a6 --- /dev/null +++ b/server-new/Cargo.lock @@ -0,0 +1,4296 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead 0.6.1", + "aes 0.9.1", + "cipher 0.5.2", + "ctr 0.10.1", + "ghash 0.6.0", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aide" +version = "0.16.0-alpha.4" +source = "git+https://github.com/hniksic/aide.git?rev=7246c20#7246c20903ce9e87b768917589a7568663a1eda3" +dependencies = [ + "aide-macros", + "axum", + "bytes", + "cfg-if", + "http", + "indexmap", + "schemars", + "serde", + "serde_json", + "serde_qs", + "thiserror 2.0.18", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "aide-macros" +version = "0.16.0-alpha.4" +source = "git+https://github.com/hniksic/aide.git?rev=7246c20#7246c20903ce9e87b768917589a7568663a1eda3" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62a5e99d6b2764d521fa86b22ca32ad96f19ae2427febfd80a131a2e3e9d6ad9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-aide-macros" +version = "0.1.0" +source = "git+https://git.fasharp.io/fa-sharp/axum-aide-macros?rev=5b00e645df#5b00e645dfec6a0a76cdfa0e9a0c9e050003faec" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-helmet" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4233d7fef77c993a0251c2e4610cc6e4e02c01bbd669f8f323ec9e8376d947c7" +dependencies = [ + "helmet-core", + "http", + "pin-project-lite", + "tower", + "tower-service", +] + +[[package]] +name = "axum-plugin" +version = "0.3.0" +source = "git+https://git.fasharp.io/fa-sharp/axum-plugin?rev=be17dc9aec#be17dc9aec0f1138131052924befe564834798f9" +dependencies = [ + "anyhow", + "axum", + "axum-plugin-macros", + "figment", + "futures", + "serde", + "type-map", +] + +[[package]] +name = "axum-plugin-macros" +version = "0.1.0" +source = "git+https://git.fasharp.io/fa-sharp/axum-plugin?rev=be17dc9aec#be17dc9aec0f1138131052924befe564834798f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", + "serde_json", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "aes-gcm 0.10.3", + "base64", + "hkdf", + "hmac 0.12.1", + "percent-encoding", + "rand 0.8.7", + "sha2 0.10.9", + "subtle", + "time", + "version_check", +] + +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "diesel" +version = "2.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e54d1f576cd3a3460f212a4615fd12ce1b6303c095b79a44449ffbe627753dc1" +dependencies = [ + "bigdecimal", + "bitflags 2.13.1", + "byteorder", + "chrono", + "diesel_derives", + "downcast-rs", + "itoa", + "num-bigint", + "num-integer", + "num-traits", + "serde_json", + "uuid", +] + +[[package]] +name = "diesel-async" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd39af30158d444884f166fe4c58f35dc40ad71ad017bb59408a3448526ff4bd" +dependencies = [ + "deadpool", + "diesel", + "diesel_migrations", + "futures-core", + "futures-util", + "pin-project-lite", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "diesel-derive-enum" +version = "3.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a8c082045d01debc8589f8a0db9f2855a37c99c9b031325c856b5b98e1625f" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diesel-jsonb-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling 0.21.3", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fred" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a7b2fd0f08b23315c13b6156f971aeedb6f75fb16a29ac1872d2eabccc1490e" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "bytes-utils", + "float-cmp", + "fred-macros", + "futures", + "log", + "parking_lot", + "rand 0.8.7", + "redis-protocol", + "semver", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tokio-util", + "url", + "urlencoding", +] + +[[package]] +name = "fred-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "helmet-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a865b9c8b67316ab132710af828252e764cdf2195bbcd72a23b96127150d9de" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "instant-xml" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2cad967c3b727c000ebfdcd14974539e8c6f59e1d044c8034179df2c6fe250" +dependencies = [ + "instant-xml-macros", + "thiserror 2.0.18", + "xmlparser", +] + +[[package]] +name = "instant-xml-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44127a3a387c070ef0656a6ce53dd0e616cf8d6cf5b159aa478cfd49e1c166e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.7", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "oauth2-reqwest" +version = "0.1.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2" +dependencies = [ + "oauth2", + "reqwest", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5", + "memchr", + "rand 0.10.2", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "progenitor-client" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8a874cf25a33cac7a01b9c1de87bcfbc8aea93f3156d09dcc3bee516a78926" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redis-protocol" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdba59219406899220fc4cdfd17a95191ba9c9afb719b5fa5a083d63109a9f1" +dependencies = [ + "bytes", + "bytes-utils", + "cookie-factory", + "crc16", + "log", + "nom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reqwest-websocket" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7705b649c3b66b85c4e9c304a6898b1ae3eecb880c474720ebf925e4a932ae02" +dependencies = [ + "async-tungstenite", + "bytes", + "futures-util", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tungstenite", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rs-chat-api" +version = "0.1.0" +dependencies = [ + "aes-gcm 0.11.0", + "aide", + "anyhow", + "async-stream", + "async-trait", + "axum", + "axum-aide-macros", + "axum-extra", + "axum-helmet", + "axum-plugin", + "bigdecimal", + "chrono", + "diesel", + "diesel-async", + "diesel-derive-enum", + "diesel-jsonb-derive", + "diesel_migrations", + "dotenvy", + "fred", + "futures", + "hex", + "reqwest", + "reqwest-websocket", + "rusty-s3", + "schemars", + "serde", + "serde_json", + "serde_with", + "simple-oauth", + "strum", + "thiserror 2.0.18", + "tinistream-client", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tower-http 0.7.0", + "tower-sessions", + "tower-sessions-redis-store", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-s3" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0d23aa8ac3b44d4cfb1e4b3611e6f3776debfb3f7701c4ea9f2252a701403" +dependencies = [ + "base64", + "hmac 0.13.0", + "instant-xml", + "jiff", + "md-5", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.11.0", + "url", + "zeroize", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "bigdecimal", + "chrono", + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332" +dependencies = [ + "axum", + "itoa", + "percent-encoding", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "serde_core", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-oauth" +version = "0.1.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0b2fb63e098c4e51b55768a3bf1b29c0180de4dda801cdd9b568d92967787f" +dependencies = [ + "bon", + "oauth2", + "oauth2-reqwest", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinistream-client" +version = "0.2.0" +source = "git+https://github.com/fa-sharp/tinistream?rev=015d307#015d3076e64b55fbc6f1577716ede64d6e66597e" +dependencies = [ + "bytes", + "futures-core", + "progenitor-client", + "reqwest", + "serde", + "serde_urlencoded", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2 0.6.5", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower-sessions" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "futures", + "http", + "parking_lot", + "rand 0.9.5", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[package]] +name = "tower-sessions-redis-store" +version = "0.16.0" +source = "git+https://github.com/maxcountryman/tower-sessions-stores?rev=69e025f#69e025f97b8b6ca54618e000375cb1aaa852209a" +dependencies = [ + "async-trait", + "fred", + "rmp-serde", + "thiserror 2.0.18", + "time", + "tower-sessions-core", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server-new/Cargo.toml b/server-new/Cargo.toml new file mode 100644 index 0000000..eef17ac --- /dev/null +++ b/server-new/Cargo.toml @@ -0,0 +1,109 @@ +[package] +name = "rs-chat-api" +version = "0.1.0" +edition = "2024" +description = "RsChat Server" +publish = false + +[dependencies] +aes-gcm = "0.11.0" +aide = { + git = "https://github.com/hniksic/aide.git", + rev = "7246c20", + features = ["axum", "axum-json", "axum-query", "macros", "swagger"] +} +anyhow = "1.0.104" +async-stream = "0.3.6" +async-trait = "0.1.90" +axum = { version = "0.8.9", features = ["json", "query"] } +axum-aide-macros = { + git = "https://git.fasharp.io/fa-sharp/axum-aide-macros", + rev = "5b00e645df" +} +axum-extra = { version = "0.12.6", features = ["file-stream"] } +axum-helmet = "1.0.2" +axum-plugin = { + git = "https://git.fasharp.io/fa-sharp/axum-plugin", + rev = "be17dc9aec", + features = ["figment"] +} +bigdecimal = { version = "0.4.10", features = ["serde-json"] } +chrono = { + version = "0.4.45", + default-features = false, + features = ["now", "serde", "std"] +} +diesel = { + version = "2.3.11", + default-features = false, + features = ["chrono", "numeric", "serde_json", "uuid"] +} +diesel-async = { + version = "0.9.2", + features = ["deadpool", "migrations", "postgres"] +} +diesel-derive-enum = { version = "3.0.0-beta.1", features = ["postgres"] } +diesel-jsonb-derive = { path = "crates/diesel-jsonb-derive" } +diesel_migrations = { version = "2.3.2", features = ["postgres"] } +dotenvy = "0.15.7" +fred = { + version = "10.1.0", + default-features = false, + features = ["i-keys", "i-streams"] +} +futures = "0.3.33" +hex = "0.4.3" +reqwest = { + version = "0.13.4", + default-features = false, + features = ["default-tls", "json", "stream"] +} +reqwest-websocket = { version = "0.6.0", features = ["json"] } +rusty-s3 = "0.10.0" +schemars = { + version = "1.2.1", + features = ["bigdecimal04", "chrono04", "preserve_order", "uuid1"] +} +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +serde_with = { + version = "3.21.0", + default-features = false, + features = ["macros"] +} +simple-oauth = { version = "0.1.0-beta.1", features = ["default-tls"] } +strum = { + version = "0.28.0", + default-features = false, + features = ["derive", "std"] +} +thiserror = "2.0.18" +tinistream-client = { + git = "https://github.com/fa-sharp/tinistream", + rev = "015d307" +} +tokio = { + version = "1.53.0", + default-features = false, + features = ["macros", "net", "rt", "rt-multi-thread", "signal"] +} +tokio-stream = { version = "0.1.18", default-features = false } +tokio-util = { version = "0.7.18", features = ["io"] } +tower = { version = "0.5", default-features = false } +tower-http = { + version = "0.7.0", + features = ["fs", "request-id", "timeout", "trace"] +} +tower-sessions = { + version = "0.15.0", + default-features = false, + features = ["axum-core", "memory-store", "private"] +} +tower-sessions-redis-store = { + git = "https://github.com/maxcountryman/tower-sessions-stores", + rev = "69e025f" +} +tracing = "0.1.44" +tracing-appender = "0.2.5" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } +uuid = { version = "1.24.0", features = ["serde", "v4"] } diff --git a/server-new/Dockerfile b/server-new/Dockerfile new file mode 100644 index 0000000..7c6c8e5 --- /dev/null +++ b/server-new/Dockerfile @@ -0,0 +1,39 @@ +# Image versions +ARG RUST_VERSION=1.96 +ARG DEBIAN_VERSION=bookworm + +### Build server ### +FROM rust:${RUST_VERSION}-slim-${DEBIAN_VERSION} AS build +WORKDIR /app + +# Copy all necessary files to build the server +COPY Cargo.lock Cargo.toml ./ +COPY ./crates ./crates +COPY ./migrations ./migrations +COPY ./src ./src + +ARG pkg=rs-chat-api + +RUN --mount=type=cache,id=rust_target,target=/app/target \ + --mount=type=cache,id=cargo_registry,target=/usr/local/cargo/registry \ + --mount=type=cache,id=cargo_git,target=/usr/local/cargo/git \ + set -eux; \ + cargo build --package $pkg --release --locked; \ + objcopy --compress-debug-sections target/release/$pkg ./run-server + + +### Run server ### +FROM debian:${DEBIAN_VERSION}-slim AS run + +RUN apt-get update -qq && \ + apt-get install ca-certificates -qq -y && \ + apt-get clean + +# Copy server binary +COPY --from=build /app/run-server /usr/local/bin/ + +# Run server +WORKDIR /app +ENV RS_CHAT_SERVER__HOST=0.0.0.0 +ENV RS_CHAT_SERVER__STATIC_PATH=/var/www +CMD ["run-server"] diff --git a/server-new/README.md b/server-new/README.md new file mode 100644 index 0000000..058045e --- /dev/null +++ b/server-new/README.md @@ -0,0 +1,134 @@ +# Axum Web Service Template + +A production-ready template for building web services with Rust and Axum. + +## Features + +- **Axum** - Fast and ergonomic web framework +- **Configuration Management** - Environment-based config with `figment` +- **Structured API Errors** - JSON error responses with an `AppError` type for route handlers +- **Structured Logging** - JSON logging in production with `tracing` +- **Secure Defaults** - Default HTTP security headers, request body limit and timeout with `tower-http` +- **Optional Request Logging** - Request IDs and HTTP request/response logs with `tower-http` +- **Graceful Shutdown** - Handles SIGTERM and SIGINT signals +- ️**Plugin Architecture** - Modular app initialization with `axum-plugin` +- **Optional OpenAPI** - API documentation with `aide` (optional) +- **Docker / OCI** - Dockerfile with sensible defaults for quick deployment + +## Usage + +### Using cargo-generate + +Install cargo-generate if you haven't already: + +```bash +cargo install cargo-generate +``` + +Generate a new project from this template: + +```bash +cargo generate --git https://git.fasharp.io/fa-sharp/axum-template +``` + +You'll be prompted for: +- **Project name**: The name of your new project +- **Project description**: A brief description +- **Environment variable prefix**: Prefix for env vars (e.g., `APP` for `APP_HOST`, `APP_PORT`) +- **Default port**: The server's default port +- **Default log level**: trace, debug, info, warn, or error +- **Include request logging**: Whether to include request ID and request/response logging middleware +- **Include aide**: Whether to include OpenAPI documentation support + +## Configuration + +Configuration is loaded from environment variables and validated in the `config.rs` file. The variable prefix is configurable during template generation. + +Example with `APP` prefix: + +```bash +# Required +APP_API_KEY=your-secret-key + +# Optional (defaults shown) +APP_HOST=127.0.0.1 +APP_PORT=8080 +APP_LOG_LEVEL=info +APP_REQUEST_ID_HEADER=x-request-id +``` + +In development, you can use the `.env` file to set environment variables. + +## Project Structure + +``` +. +├── src/ +│ ├── routes/ # API routes +│ ├── plugins/ # Axum plugins +│ ├── config.rs # Configuration management +│ ├── error.rs # Structured API error handling +│ ├── lib.rs # Axum server setup +│ ├── main.rs # Entry point +│ └── state.rs # Axum server state +├── Cargo.toml # Dependencies +├── .env # Local environment variables +└── .env.example # Example environment variables +``` + +## Development + +```bash +# Run in development mode (loads .env file) +cargo run + +# Run with custom log level +APP_LOG_LEVEL=debug cargo run + +# Build for production +cargo build --release +``` + +## Adding Routes + +This template uses `axum-plugin` for modular initialization. To add routes: + +1. Create a new plugin in a separate module +2. Register it in `lib.rs`: + +```rust +pub async fn create_app() -> anyhow::Result> { + let app = App::new() + .register(config::plugin()) + .register(your_routes::plugin()) // Add your plugin here + .init() + .await?; + + Ok(app) +} +``` + +## Middleware Plugins + +The template includes a `security` plugin by default. It adds common response headers, as well as a request body limiter and timeout using `tower::ServiceBuilder` and `tower-http`. + +When request logging is enabled during generation, the template also includes a `logging` plugin that adds request IDs and request/response tracing. + +## Error Handling + +Route handlers can return `AppResult`, which is an alias for `Result`. `AppError` implements `IntoResponse`, so API failures are returned as JSON. It also implements `From`, so handlers can use `?` with `anyhow` errors: + +```rust +use anyhow::Context; + +use crate::error::AppResult; + +async fn handler() -> AppResult { + do_work().await.context("failed to do work")?; + Ok("done".to_string()) +} +``` + +## License + +Configure your license as needed. diff --git a/server-new/bacon.toml b/server-new/bacon.toml new file mode 100644 index 0000000..8d5e596 --- /dev/null +++ b/server-new/bacon.toml @@ -0,0 +1,6 @@ +[jobs.dev] +command = ["cargo", "run"] +need_stdout = true +background = false +on_change_strategy = "kill_then_restart" +kill = ["kill", "-s", "INT"] diff --git a/server-new/config.toml b/server-new/config.toml new file mode 100644 index 0000000..289f384 --- /dev/null +++ b/server-new/config.toml @@ -0,0 +1,18 @@ +# Configuration for local development + +[server] +host = "127.0.0.1" +port = 8080 +base_url = "http://localhost:8080" +log_level = "info" +data_dir = ".local" + +[database] +url = "postgres://postgres:postgres@localhost/postgres" + +[redis] +url = "redis://localhost:6379" + +[services] +streamer_url = "http://localhost:8081" +streamer_api_key = "dev-streamer-api-key" diff --git a/server-new/crates/diesel-jsonb-derive/Cargo.toml b/server-new/crates/diesel-jsonb-derive/Cargo.toml new file mode 100644 index 0000000..fc9db4e --- /dev/null +++ b/server-new/crates/diesel-jsonb-derive/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "diesel-jsonb-derive" +version = "0.1.0" +edition = "2024" +description = "Internal derive macro for Diesel JSONB serde conversion" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["derive"] } diff --git a/server-new/crates/diesel-jsonb-derive/src/lib.rs b/server-new/crates/diesel-jsonb-derive/src/lib.rs new file mode 100644 index 0000000..2b2db5f --- /dev/null +++ b/server-new/crates/diesel-jsonb-derive/src/lib.rs @@ -0,0 +1,46 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, parse_macro_input}; + +#[proc_macro_derive(AsJsonb)] +pub fn derive_as_jsonb(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let ident = input.ident; + let generics = input.generics; + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + quote! { + impl #impl_generics diesel::deserialize::FromSql + for #ident #ty_generics + #where_clause + { + fn from_sql(bytes: diesel::pg::PgValue<'_>) -> diesel::deserialize::Result { + let value = + >::from_sql(bytes)?; + + Ok(serde_json::from_value(value)?) + } + } + + impl #impl_generics diesel::serialize::ToSql + for #ident #ty_generics + #where_clause + { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, diesel::pg::Pg>, + ) -> diesel::serialize::Result { + let value = serde_json::to_value(self)?; + + >::to_sql(&value, &mut out.reborrow()) + } + } + } + .into() +} diff --git a/server-new/diesel.toml b/server-new/diesel.toml new file mode 100644 index 0000000..ad7fef6 --- /dev/null +++ b/server-new/diesel.toml @@ -0,0 +1,6 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/db/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] diff --git a/server-new/migrations/00000000000000_diesel_initial_setup/down.sql b/server-new/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/server-new/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/server-new/migrations/00000000000000_diesel_initial_setup/up.sql b/server-new/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/server-new/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/server-new/migrations/2025-06-12-171524_create_chat_sessions/down.sql b/server-new/migrations/2025-06-12-171524_create_chat_sessions/down.sql new file mode 100644 index 0000000..0f76f0b --- /dev/null +++ b/server-new/migrations/2025-06-12-171524_create_chat_sessions/down.sql @@ -0,0 +1,5 @@ +DROP TABLE chat_messages; + +DROP TYPE chat_message_role; + +DROP TABLE chat_sessions; diff --git a/server-new/migrations/2025-06-12-171524_create_chat_sessions/up.sql b/server-new/migrations/2025-06-12-171524_create_chat_sessions/up.sql new file mode 100644 index 0000000..55db20f --- /dev/null +++ b/server-new/migrations/2025-06-12-171524_create_chat_sessions/up.sql @@ -0,0 +1,25 @@ +CREATE TABLE chat_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + title VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW (), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW () +); + +SELECT + diesel_manage_updated_at ('chat_sessions'); + +CREATE TYPE chat_message_role AS ENUM ('user', 'assistant', 'system'); + +CREATE TABLE chat_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + session_id UUID NOT NULL REFERENCES chat_sessions (id) ON UPDATE CASCADE ON DELETE CASCADE, + role chat_message_role NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW (), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW () +); + +CREATE INDEX chat_messages_session_id_idx ON chat_messages (session_id); + +SELECT + diesel_manage_updated_at ('chat_messages'); diff --git a/server-new/migrations/2025-06-15-031207_add_users/down.sql b/server-new/migrations/2025-06-15-031207_add_users/down.sql new file mode 100644 index 0000000..b896b01 --- /dev/null +++ b/server-new/migrations/2025-06-15-031207_add_users/down.sql @@ -0,0 +1,6 @@ +DROP INDEX chat_sessions_user_id_idx; + +ALTER TABLE chat_sessions +DROP COLUMN user_id; + +DROP TABLE users; diff --git a/server-new/migrations/2025-06-15-031207_add_users/up.sql b/server-new/migrations/2025-06-15-031207_add_users/up.sql new file mode 100644 index 0000000..c580279 --- /dev/null +++ b/server-new/migrations/2025-06-15-031207_add_users/up.sql @@ -0,0 +1,15 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + github_id VARCHAR NOT NULL, + name VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW (), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW () +); + +SELECT + diesel_manage_updated_at ('users'); + +ALTER TABLE chat_sessions +ADD COLUMN user_id UUID NOT NULL REFERENCES users (id); + +CREATE INDEX chat_sessions_user_id_idx ON chat_sessions (user_id); diff --git a/server-new/migrations/2025-06-16-035815_add_api_keys/down.sql b/server-new/migrations/2025-06-16-035815_add_api_keys/down.sql new file mode 100644 index 0000000..031e93a --- /dev/null +++ b/server-new/migrations/2025-06-16-035815_add_api_keys/down.sql @@ -0,0 +1,3 @@ +DROP TABLE api_keys; + +DROP TYPE llm_provider; diff --git a/server-new/migrations/2025-06-16-035815_add_api_keys/up.sql b/server-new/migrations/2025-06-16-035815_add_api_keys/up.sql new file mode 100644 index 0000000..b3c1168 --- /dev/null +++ b/server-new/migrations/2025-06-16-035815_add_api_keys/up.sql @@ -0,0 +1,12 @@ +CREATE TYPE llm_provider AS ENUM('anthropic', 'openai', 'ollama', 'deepseek', 'google', 'openrouter'); + +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id), + provider llm_provider NOT NULL, + ciphertext BYTEA NOT NULL, + nonce BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX api_keys_user_id_idx ON api_keys (user_id); diff --git a/server-new/migrations/2025-06-17-063104_add_message_meta/down.sql b/server-new/migrations/2025-06-17-063104_add_message_meta/down.sql new file mode 100644 index 0000000..9f6c31a --- /dev/null +++ b/server-new/migrations/2025-06-17-063104_add_message_meta/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_messages +DROP COLUMN meta; diff --git a/server-new/migrations/2025-06-17-063104_add_message_meta/up.sql b/server-new/migrations/2025-06-17-063104_add_message_meta/up.sql new file mode 100644 index 0000000..73bc069 --- /dev/null +++ b/server-new/migrations/2025-06-17-063104_add_message_meta/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_messages +ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'; diff --git a/server-new/migrations/2025-06-21-031403_full_text_search/down.sql b/server-new/migrations/2025-06-21-031403_full_text_search/down.sql new file mode 100644 index 0000000..2a59b48 --- /dev/null +++ b/server-new/migrations/2025-06-21-031403_full_text_search/down.sql @@ -0,0 +1,10 @@ +DROP TRIGGER chat_sessions_search_vector_update on chat_sessions; + +DROP TRIGGER chat_messages_search_vector_update on chat_messages; + +DROP FUNCTION chat_sessions_search_vector_update; + +DROP FUNCTION chat_messages_search_vector_update; + +ALTER TABLE chat_messages +DROP COLUMN search_vector; diff --git a/server-new/migrations/2025-06-21-031403_full_text_search/up.sql b/server-new/migrations/2025-06-21-031403_full_text_search/up.sql new file mode 100644 index 0000000..0cbb2f7 --- /dev/null +++ b/server-new/migrations/2025-06-21-031403_full_text_search/up.sql @@ -0,0 +1,53 @@ +ALTER TABLE chat_messages +ADD COLUMN search_vector tsvector NOT NULL DEFAULT ''; + +CREATE INDEX chat_messages_search_vector_idx ON chat_messages USING GIN (search_vector); + +UPDATE chat_messages +SET + search_vector = setweight( + to_tsvector( + 'english', + ( + SELECT + title + FROM + chat_sessions + WHERE + id = session_id + ) + ), + 'A' + ) || setweight(to_tsvector('english', "content"), 'B'); + +CREATE OR REPLACE FUNCTION chat_messages_search_vector_update () RETURNS trigger AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('english', ( + SELECT title FROM chat_sessions WHERE id = NEW.session_id + )), 'A') || setweight(to_tsvector('english', NEW."content"), 'B'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION chat_sessions_search_vector_update () RETURNS trigger AS $$ + BEGIN + IF old.title = new.title THEN RETURN NEW; END IF; + UPDATE chat_messages + SET search_vector = + setweight(to_tsvector('english', NEW.title), 'A') || setweight(to_tsvector('english', "content"), 'B') + WHERE session_id = NEW.id; + RETURN NEW; + END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER chat_messages_search_vector_update BEFORE INSERT +OR +UPDATE ON chat_messages FOR EACH ROW +EXECUTE FUNCTION chat_messages_search_vector_update (); + +CREATE TRIGGER chat_sessions_search_vector_update +AFTER INSERT +OR +UPDATE ON chat_sessions FOR EACH ROW +EXECUTE FUNCTION chat_sessions_search_vector_update (); diff --git a/server-new/migrations/2025-06-23-023453_update_session_updated_at/down.sql b/server-new/migrations/2025-06-23-023453_update_session_updated_at/down.sql new file mode 100644 index 0000000..4d4a89d --- /dev/null +++ b/server-new/migrations/2025-06-23-023453_update_session_updated_at/down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER chat_messages_update_session_updated_at ON chat_messages; + +DROP FUNCTION chat_messages_update_session_updated_at (); diff --git a/server-new/migrations/2025-06-23-023453_update_session_updated_at/up.sql b/server-new/migrations/2025-06-23-023453_update_session_updated_at/up.sql new file mode 100644 index 0000000..3485074 --- /dev/null +++ b/server-new/migrations/2025-06-23-023453_update_session_updated_at/up.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE FUNCTION chat_messages_update_session_updated_at () RETURNS TRIGGER AS $$ +BEGIN + UPDATE chat_sessions SET updated_at = NOW() WHERE id = NEW.session_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER chat_messages_update_session_updated_at BEFORE INSERT +OR +UPDATE ON chat_messages FOR EACH ROW +EXECUTE FUNCTION chat_messages_update_session_updated_at (); diff --git a/server-new/migrations/2025-07-10-165227_add_auth_providers/down.sql b/server-new/migrations/2025-07-10-165227_add_auth_providers/down.sql new file mode 100644 index 0000000..c0098b8 --- /dev/null +++ b/server-new/migrations/2025-07-10-165227_add_auth_providers/down.sql @@ -0,0 +1,16 @@ +-- Drop all unique constraints first +ALTER TABLE users +DROP CONSTRAINT IF EXISTS github_id_unique, +DROP CONSTRAINT IF EXISTS google_id_unique, +DROP CONSTRAINT IF EXISTS discord_id_unique, +DROP CONSTRAINT IF EXISTS oidc_id_unique; + +-- Drop all added columns and restore github_id constraint +ALTER TABLE users +DROP COLUMN avatar_url, +DROP COLUMN oidc_id, +DROP COLUMN discord_id, +DROP COLUMN google_id, +DROP COLUMN sso_username, +ALTER COLUMN github_id +SET NOT NULL; diff --git a/server-new/migrations/2025-07-10-165227_add_auth_providers/up.sql b/server-new/migrations/2025-07-10-165227_add_auth_providers/up.sql new file mode 100644 index 0000000..5b323b1 --- /dev/null +++ b/server-new/migrations/2025-07-10-165227_add_auth_providers/up.sql @@ -0,0 +1,16 @@ +-- Migration: Add support for multiple auth providers and avatars +ALTER TABLE users +ALTER COLUMN github_id +DROP NOT NULL, +ADD COLUMN sso_username TEXT, +ADD COLUMN google_id TEXT, +ADD COLUMN discord_id TEXT, +ADD COLUMN oidc_id TEXT, +ADD COLUMN avatar_url TEXT; + +-- Add unique constraints for all provider IDs +ALTER TABLE users +ADD CONSTRAINT github_id_unique UNIQUE (github_id), +ADD CONSTRAINT google_id_unique UNIQUE (google_id), +ADD CONSTRAINT discord_id_unique UNIQUE (discord_id), +ADD CONSTRAINT oidc_id_unique UNIQUE (oidc_id); diff --git a/server-new/migrations/2025-07-11-012329_add_app_api_keys/down.sql b/server-new/migrations/2025-07-11-012329_add_app_api_keys/down.sql new file mode 100644 index 0000000..5e20619 --- /dev/null +++ b/server-new/migrations/2025-07-11-012329_add_app_api_keys/down.sql @@ -0,0 +1 @@ +DROP TABLE app_api_keys; diff --git a/server-new/migrations/2025-07-11-012329_add_app_api_keys/up.sql b/server-new/migrations/2025-07-11-012329_add_app_api_keys/up.sql new file mode 100644 index 0000000..dd1d398 --- /dev/null +++ b/server-new/migrations/2025-07-11-012329_add_app_api_keys/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE app_api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX app_api_keys_user_id_idx ON app_api_keys (user_id); diff --git a/server-new/migrations/2025-07-13-170127_add_tools/down.sql b/server-new/migrations/2025-07-13-170127_add_tools/down.sql new file mode 100644 index 0000000..0e424e7 --- /dev/null +++ b/server-new/migrations/2025-07-13-170127_add_tools/down.sql @@ -0,0 +1,13 @@ +-- Remove 'tool' from chat_message_role +ALTER TYPE chat_message_role +RENAME TO chat_message_role_old; + +CREATE TYPE chat_message_role AS ENUM('user', 'assistant', 'system'); + +ALTER TABLE chat_messages +ALTER COLUMN role TYPE chat_message_role USING role::text::chat_message_role; + +DROP TYPE chat_message_role_old; + +-- Drop tools table +DROP TABLE tools; diff --git a/server-new/migrations/2025-07-13-170127_add_tools/up.sql b/server-new/migrations/2025-07-13-170127_add_tools/up.sql new file mode 100644 index 0000000..b1e70ef --- /dev/null +++ b/server-new/migrations/2025-07-13-170127_add_tools/up.sql @@ -0,0 +1,26 @@ +-- Add tools table +CREATE TABLE tools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id), + name TEXT NOT NULL, + description TEXT NOT NULL, + config JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +SELECT + diesel_manage_updated_at ('tools'); + +CREATE INDEX tools_user_id_idx ON tools (user_id); + +-- Add tool role to chat messages +ALTER TYPE chat_message_role +RENAME TO chat_message_role_old; + +CREATE TYPE chat_message_role AS ENUM('user', 'assistant', 'system', 'tool'); + +ALTER TABLE chat_messages +ALTER COLUMN role TYPE chat_message_role USING role::text::chat_message_role; + +DROP TYPE chat_message_role_old; diff --git a/server-new/migrations/2025-07-17-223807_add_providers/down.sql b/server-new/migrations/2025-07-17-223807_add_providers/down.sql new file mode 100644 index 0000000..02d8f8a --- /dev/null +++ b/server-new/migrations/2025-07-17-223807_add_providers/down.sql @@ -0,0 +1,7 @@ +ALTER TABLE secrets +DROP COLUMN name; + +DROP TABLE providers; + +ALTER TABLE secrets +RENAME TO api_keys; diff --git a/server-new/migrations/2025-07-17-223807_add_providers/up.sql b/server-new/migrations/2025-07-17-223807_add_providers/up.sql new file mode 100644 index 0000000..a16799f --- /dev/null +++ b/server-new/migrations/2025-07-17-223807_add_providers/up.sql @@ -0,0 +1,68 @@ +-- Rename API keys table to secrets +ALTER TABLE api_keys +RENAME TO secrets; + +-- Create providers table +CREATE TABLE providers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + provider_type TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users (id), + base_url TEXT, + default_model TEXT NOT NULL, + api_key_id UUID REFERENCES secrets (id) ON UPDATE CASCADE ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_providers_user_id ON providers (user_id); + +-- Create providers for users with existing API keys +INSERT INTO + providers (provider_type, name, user_id, base_url, default_model, api_key_id) +SELECT + 'openai', + 'OpenAI', + secrets.user_id, + NULL, + 'gpt-4o-mini', + id +FROM + secrets +WHERE + secrets.provider = 'openai'; + +INSERT INTO + providers (provider_type, name, user_id, base_url, default_model, api_key_id) +SELECT + 'openai', + 'OpenRouter', + secrets.user_id, + 'https://openrouter.ai/api/v1', + 'openai/gpt-4o-mini', + id +FROM + secrets +WHERE + secrets.provider = 'openrouter'; + +INSERT INTO + providers (provider_type, name, user_id, base_url, default_model, api_key_id) +SELECT + 'anthropic', + 'Anthropic', + secrets.user_id, + NULL, + 'claude-3-7-sonnet-latest', + id +FROM + secrets +WHERE + secrets.provider = 'anthropic'; + +-- Add name to secrets table +ALTER TABLE secrets +ADD COLUMN name TEXT NOT NULL DEFAULT 'api_key'; + +UPDATE secrets +SET + name = secrets.provider || '_api_key'; diff --git a/server-new/migrations/2025-08-08-080101_add_session_meta/down.sql b/server-new/migrations/2025-08-08-080101_add_session_meta/down.sql new file mode 100644 index 0000000..b090cff --- /dev/null +++ b/server-new/migrations/2025-08-08-080101_add_session_meta/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_sessions +DROP COLUMN meta; diff --git a/server-new/migrations/2025-08-08-080101_add_session_meta/up.sql b/server-new/migrations/2025-08-08-080101_add_session_meta/up.sql new file mode 100644 index 0000000..7ef1194 --- /dev/null +++ b/server-new/migrations/2025-08-08-080101_add_session_meta/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_sessions +ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'; diff --git a/server-new/migrations/2025-08-09-160537_remove_unused_secret_field/down.sql b/server-new/migrations/2025-08-09-160537_remove_unused_secret_field/down.sql new file mode 100644 index 0000000..4c9c7ea --- /dev/null +++ b/server-new/migrations/2025-08-09-160537_remove_unused_secret_field/down.sql @@ -0,0 +1,4 @@ +CREATE TYPE llm_provider AS ENUM('anthropic', 'openai', 'ollama', 'deepseek', 'google', 'openrouter'); + +ALTER TABLE secrets +ADD COLUMN provider llm_provider NOT NULL DEFAULT 'openai'; diff --git a/server-new/migrations/2025-08-09-160537_remove_unused_secret_field/up.sql b/server-new/migrations/2025-08-09-160537_remove_unused_secret_field/up.sql new file mode 100644 index 0000000..af9e4aa --- /dev/null +++ b/server-new/migrations/2025-08-09-160537_remove_unused_secret_field/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE secrets +DROP COLUMN provider; + +DROP TYPE llm_provider; diff --git a/server-new/migrations/2025-08-16-152113_refactor_tools/down.sql b/server-new/migrations/2025-08-16-152113_refactor_tools/down.sql new file mode 100644 index 0000000..6d82328 --- /dev/null +++ b/server-new/migrations/2025-08-16-152113_refactor_tools/down.sql @@ -0,0 +1,3 @@ +DROP TABLE external_api_tools; + +DROP TABLE system_tools; diff --git a/server-new/migrations/2025-08-16-152113_refactor_tools/up.sql b/server-new/migrations/2025-08-16-152113_refactor_tools/up.sql new file mode 100644 index 0000000..b5bdd71 --- /dev/null +++ b/server-new/migrations/2025-08-16-152113_refactor_tools/up.sql @@ -0,0 +1,23 @@ +CREATE TABLE system_tools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id), + data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +SELECT + diesel_manage_updated_at ('system_tools'); + +CREATE TABLE external_api_tools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id), + data JSONB NOT NULL, + secret_1 UUID REFERENCES secrets (id) ON UPDATE CASCADE ON DELETE SET NULL, + secret_2 UUID REFERENCES secrets (id) ON UPDATE CASCADE ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +SELECT + diesel_manage_updated_at ('external_api_tools'); diff --git a/server-new/migrations/2025-08-31-034235_add_files/down.sql b/server-new/migrations/2025-08-31-034235_add_files/down.sql new file mode 100644 index 0000000..38a7300 --- /dev/null +++ b/server-new/migrations/2025-08-31-034235_add_files/down.sql @@ -0,0 +1 @@ +DROP TABLE files; diff --git a/server-new/migrations/2025-08-31-034235_add_files/up.sql b/server-new/migrations/2025-08-31-034235_add_files/up.sql new file mode 100644 index 0000000..bbbc781 --- /dev/null +++ b/server-new/migrations/2025-08-31-034235_add_files/up.sql @@ -0,0 +1,18 @@ +CREATE TABLE files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id), + session_id UUID REFERENCES chat_sessions (id) ON UPDATE CASCADE ON DELETE SET NULL, + path TEXT NOT NULL, + file_type TEXT NOT NULL, + content_type TEXT NOT NULL, + size INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +SELECT + diesel_manage_updated_at ('files'); + +CREATE INDEX idx_files_user_id ON files (user_id); + +CREATE INDEX idx_files_session_id ON files (session_id); diff --git a/server-new/migrations/2025-09-03-063406_remove_old_tools/down.sql b/server-new/migrations/2025-09-03-063406_remove_old_tools/down.sql new file mode 100644 index 0000000..dab05c2 --- /dev/null +++ b/server-new/migrations/2025-09-03-063406_remove_old_tools/down.sql @@ -0,0 +1,15 @@ +-- Add back tools table +CREATE TABLE tools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id), + name TEXT NOT NULL, + description TEXT NOT NULL, + config JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +SELECT + diesel_manage_updated_at ('tools'); + +CREATE INDEX tools_user_id_idx ON tools (user_id); diff --git a/server-new/migrations/2025-09-03-063406_remove_old_tools/up.sql b/server-new/migrations/2025-09-03-063406_remove_old_tools/up.sql new file mode 100644 index 0000000..f019705 --- /dev/null +++ b/server-new/migrations/2025-09-03-063406_remove_old_tools/up.sql @@ -0,0 +1,2 @@ +-- Drop old tools table +DROP TABLE tools; diff --git a/server-new/migrations/2026-06-22-224357-0000_add_auth_sessions/down.sql b/server-new/migrations/2026-06-22-224357-0000_add_auth_sessions/down.sql new file mode 100644 index 0000000..25b7a7f --- /dev/null +++ b/server-new/migrations/2026-06-22-224357-0000_add_auth_sessions/down.sql @@ -0,0 +1 @@ +DROP TABLE auth_sessions; diff --git a/server-new/migrations/2026-06-22-224357-0000_add_auth_sessions/up.sql b/server-new/migrations/2026-06-22-224357-0000_add_auth_sessions/up.sql new file mode 100644 index 0000000..f89d8f2 --- /dev/null +++ b/server-new/migrations/2026-06-22-224357-0000_add_auth_sessions/up.sql @@ -0,0 +1,15 @@ +CREATE TABLE auth_sessions ( + id UUID PRIMARY KEY, + user_id UUID NULL REFERENCES users (id), + data JSONB NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +SELECT + diesel_manage_updated_at ('auth_sessions'); + +CREATE INDEX auth_sessions_user_id_idx ON auth_sessions (user_id); + +CREATE INDEX auth_sessions_expires_at_idx ON auth_sessions (expires_at); diff --git a/server-new/migrations/2026-07-02-133817-0000_add_openai_subtype/down.sql b/server-new/migrations/2026-07-02-133817-0000_add_openai_subtype/down.sql new file mode 100644 index 0000000..3ed05a0 --- /dev/null +++ b/server-new/migrations/2026-07-02-133817-0000_add_openai_subtype/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE providers +DROP COLUMN openai_subtype; diff --git a/server-new/migrations/2026-07-02-133817-0000_add_openai_subtype/up.sql b/server-new/migrations/2026-07-02-133817-0000_add_openai_subtype/up.sql new file mode 100644 index 0000000..31027f9 --- /dev/null +++ b/server-new/migrations/2026-07-02-133817-0000_add_openai_subtype/up.sql @@ -0,0 +1,8 @@ +ALTER TABLE providers +ADD COLUMN openai_subtype TEXT; + +UPDATE providers +SET + openai_subtype = 'openrouter' +WHERE + base_url = 'https://openrouter.ai/api/v1'; diff --git a/server-new/migrations/2026-07-15-053539-0000_add_request_logs/down.sql b/server-new/migrations/2026-07-15-053539-0000_add_request_logs/down.sql new file mode 100644 index 0000000..e30a71a --- /dev/null +++ b/server-new/migrations/2026-07-15-053539-0000_add_request_logs/down.sql @@ -0,0 +1 @@ +DROP TABLE llm_logs; diff --git a/server-new/migrations/2026-07-15-053539-0000_add_request_logs/up.sql b/server-new/migrations/2026-07-15-053539-0000_add_request_logs/up.sql new file mode 100644 index 0000000..b47a657 --- /dev/null +++ b/server-new/migrations/2026-07-15-053539-0000_add_request_logs/up.sql @@ -0,0 +1,75 @@ +-- Create LLM request logs table +CREATE TABLE llm_logs ( + id SERIAL PRIMARY KEY, + kind text NOT NULL, -- chat, title, prompt, image, audio, etc. + user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, + provider_id integer REFERENCES providers (id) ON DELETE SET NULL, + session_id uuid REFERENCES chat_sessions (id) ON DELETE SET NULL, + message_id uuid REFERENCES chat_messages (id) ON DELETE SET NULL, + model text NOT NULL, + input_tokens integer, + output_tokens integer, + cost numeric(12, 6), + ttft_ms integer, -- time to first token in milliseconds + status text NOT NULL, -- started, completed, failed, cancelled + meta jsonb NOT NULL DEFAULT '{}', -- max_tokens, temperature, etc. + started_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz +); + +CREATE INDEX llm_logs_user_id_started_at_idx ON llm_logs (user_id, started_at DESC); + +CREATE INDEX llm_logs_session_id_idx ON llm_logs (session_id); + +CREATE UNIQUE INDEX llm_logs_message_id_unique_idx ON llm_logs (message_id) +WHERE + message_id IS NOT NULL; + +CREATE INDEX llm_logs_provider_id_started_at_idx ON llm_logs (provider_id, started_at DESC); + +-- Migrate assistant metadata to LLM request logs table +INSERT INTO + llm_logs ( + kind, + user_id, + session_id, + message_id, + provider_id, + model, + input_tokens, + output_tokens, + cost, + status, + meta, + started_at + ) +SELECT + 'chat', + chat_sessions.user_id, + session_id, + chat_messages.id, + providers.id, + coalesce((chat_messages.meta #>> '{assistant,provider_options,model}')::text, ''), + (chat_messages.meta #>> '{assistant,usage,input_tokens}')::int4, + (chat_messages.meta #>> '{assistant,usage,output_tokens}')::int4, + (chat_messages.meta #>> '{assistant,usage,cost}')::numeric(12, 6), + CASE + WHEN (chat_messages.meta #>> '{assistant,partial}')::bool THEN 'cancelled' + WHEN chat_messages.meta @? '$.assistant.errors[0]' THEN 'error' + ELSE 'completed' + END, + jsonb_strip_nulls( + jsonb_build_object( + 'options', + chat_messages.meta #> '{assistant,provider_options}', + 'errors', + chat_messages.meta #> '{assistant,errors}' + ) + ), + chat_messages.created_at +FROM + chat_messages + JOIN chat_sessions ON chat_sessions.id = chat_messages.session_id + LEFT JOIN providers ON providers.id = (chat_messages.meta #>> '{assistant,provider_id}')::int4 +WHERE + chat_messages.role = 'assistant'; diff --git a/server-new/migrations/2026-07-17-043348-0000_add_message_attachments/down.sql b/server-new/migrations/2026-07-17-043348-0000_add_message_attachments/down.sql new file mode 100644 index 0000000..75cb30b --- /dev/null +++ b/server-new/migrations/2026-07-17-043348-0000_add_message_attachments/down.sql @@ -0,0 +1 @@ +DROP TABLE message_attachments; diff --git a/server-new/migrations/2026-07-17-043348-0000_add_message_attachments/up.sql b/server-new/migrations/2026-07-17-043348-0000_add_message_attachments/up.sql new file mode 100644 index 0000000..bc170bc --- /dev/null +++ b/server-new/migrations/2026-07-17-043348-0000_add_message_attachments/up.sql @@ -0,0 +1,6 @@ +-- Create table tracking file attachments to messaages +CREATE TABLE message_attachments ( + message_id uuid REFERENCES chat_messages (id) ON DELETE CASCADE, + file_id uuid REFERENCES files (id) ON DELETE CASCADE, + PRIMARY KEY (message_id, file_id) +); diff --git a/server-new/src/api/api_key.rs b/server-new/src/api/api_key.rs new file mode 100644 index 0000000..240934c --- /dev/null +++ b/server-new/src/api/api_key.rs @@ -0,0 +1,69 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use axum_aide_macros::api_routes; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + api::ApiTag, + db::models::ChatRsApiKey, + error::{AppError, AppResult}, + extractors::{CurrentUser, Database}, + state::AppState, +}; + +api_routes! { + state: AppState, + tag: ApiTag::ApiKey.into(), + GET "/" => list_api_keys, "List API keys"; + POST "/" => create_api_key, "Create API key"; + DELETE "/{id}" => delete_api_key, "Delete API key"; +} + +async fn list_api_keys( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, +) -> AppResult>> { + let keys = db.api_keys().find_by_user_id(&user_id).await?; + Ok(Json(keys)) +} + +#[derive(Deserialize, JsonSchema)] +struct ApiKeyCreateInput { + name: String, +} + +async fn create_api_key( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + State(state): State, + input: Json, +) -> AppResult> { + let (id, key) = state + .auth_service() + .api_keys() + .create_api_key(&mut db, &user_id, &input.name) + .await?; + Ok(Json(ApiKeyCreateResponse { id, key })) +} + +#[derive(Serialize, JsonSchema)] +struct ApiKeyCreateResponse { + id: Uuid, + key: String, +} + +async fn delete_api_key( + Path(id): Path, + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, +) -> AppResult { + match db.api_keys().delete(&user_id, &id).await? { + Some(_) => Ok(StatusCode::NO_CONTENT), + None => Err(AppError::not_found("API key not found")), + } +} diff --git a/server-new/src/api/auth.rs b/server-new/src/api/auth.rs new file mode 100644 index 0000000..2d2d8e1 --- /dev/null +++ b/server-new/src/api/auth.rs @@ -0,0 +1,121 @@ +use axum::{ + Extension, Json, + extract::{Path, Query, State}, + http::StatusCode, + response::Redirect, +}; +use axum_aide_macros::api_routes; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + api::{ApiTag, RoutePrefix}, + db::models::{ChatRsAuthSession, ChatRsUser}, + error::AppResult, + extractors::{AppSession, CurrentUser, Database, PublicAuthConfig}, + services::auth::oauth::OAuthProviderEnum, + state::AppState, +}; + +api_routes! { + state: AppState, + tag: ApiTag::Auth.into(), + GET "/user" => get_user, "Get current user"; + GET "/sessions" => list_active_sessions, "List active sessions"; + GET "/config" => get_auth_config, "Get auth config", { + description: "Get the current auth configuration of the server" + }; + GET "/login/{provider}" => oauth_login, "OAuth login", { + responses: { 303: () } + }; + GET "/login/{provider}/callback" => oauth_callback, "OAuth login callback", { + responses: { 303: () } + }; + GET, POST "/logout" => logout, "Logout", { + responses: { 204: () } + }; +} + +async fn get_user( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + State(state): State, +) -> AppResult> { + let user = state.auth_service().get_user(&mut db, &user_id).await?; + Ok(Json(user)) +} + +async fn list_active_sessions( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, +) -> AppResult>> { + let sessions = db.auth_sessions().list_active_by_user_id(&user_id).await?; + Ok(Json(sessions)) +} + +async fn get_auth_config(auth_config: PublicAuthConfig) -> Json { + Json(auth_config) +} + +fn oauth_callback_path(route_prefix: &'static str, provider: &OAuthProviderEnum) -> String { + format!("{route_prefix}/login/{provider}/callback") +} + +async fn oauth_login( + Path(provider): Path, + Extension(RoutePrefix(prefix)): Extension, + State(state): State, + AppSession { session, .. }: AppSession, +) -> AppResult { + let oauth = state.auth_service().oauth(); + let auth_url = oauth + .authorize_url(&provider, &oauth_callback_path(prefix, &provider), &session) + .await?; + + Ok(Redirect::to(auth_url.as_str())) +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct OAuthCallbackQuery { + code: String, + state: String, +} + +async fn oauth_callback( + Path(provider): Path, + Query(query): Query, + maybe_user: Option, + Extension(RoutePrefix(prefix)): Extension, + AppSession { session, meta }: AppSession, + Database(mut db): Database, + State(state): State, +) -> AppResult { + let oauth = state.auth_service().oauth(); + let token = oauth + .exchange_code( + &provider, + &oauth_callback_path(prefix, &provider), + &session, + &query.code, + &query.state, + ) + .await?; + let user = oauth + .get_user(&mut db, &provider, &token, maybe_user) + .await?; + state + .auth_service() + .session() + .login(&session, &meta, &user.id) + .await?; + + Ok(Redirect::to(&state.config.server.base_url)) +} + +async fn logout( + AppSession { session, .. }: AppSession, + State(state): State, +) -> AppResult { + state.auth_service().session().logout(&session).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/server-new/src/api/chat.rs b/server-new/src/api/chat.rs new file mode 100644 index 0000000..8d21cd5 --- /dev/null +++ b/server-new/src/api/chat.rs @@ -0,0 +1,202 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use axum_aide_macros::api_routes; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + api::ApiTag, + error::{AppError, AppResult}, + extractors::{CurrentUser, Database}, + llm::types::{LlmChatOptions, LlmUserMessage}, + state::AppState, +}; + +api_routes! { + state: AppState, + tag: ApiTag::Chat.into(), + POST "/prompt" => prompt, "Prompt"; + GET "/session" => get_active_streams, "Get sessions with active streams"; + GET "/session/{session_id}" => connect_chat_stream, "Access active chat stream"; + POST "/session/{session_id}" => chat_stream, "Stream chat session response"; + POST "/session/{session_id}/cancel" => cancel_chat_stream, "Cancel active chat stream"; + POST "/session/{session_id}/regenerate" => regenerate_response, "Regenerate chat response"; +} + +async fn get_active_streams( + CurrentUser { user_id }: CurrentUser, + State(state): State, +) -> AppResult> { + let sessions = state + .chat_service() + .active_stream_sessions(&user_id) + .await?; + + Ok(Json(ActiveStreamsResponse { sessions })) +} + +async fn prompt( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + State(state): State, + Json(PromptInput { + message, + provider_id, + options, + }): Json, +) -> AppResult> { + let llm_provider = state + .provider_service() + .build_llm_provider(&mut db, &user_id, provider_id) + .await?; + let prompt = LlmUserMessage { + text: message, + ..Default::default() + }; + let stream_access = state + .chat_service() + .prompt(&mut db, user_id, provider_id, llm_provider, prompt, options) + .await?; + + Ok(Json(StreamAccess { + url: stream_access.sse_url, + token: stream_access.token, + })) +} + +async fn chat_stream( + CurrentUser { user_id }: CurrentUser, + Path(session_id): Path, + Database(mut db): Database, + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let llm_provider = state + .provider_service() + .build_llm_provider(&mut db, &user_id, input.provider_id) + .await?; + let user_message = input + .message + .map(|text| LlmUserMessage { text, files: None }); + let stream_access = state + .chat_service() + .stream_user_chat( + &mut db, + user_id, + session_id, + input.provider_id, + llm_provider, + user_message, + input.options, + ) + .await?; + + Ok(Json(StreamAccess { + url: stream_access.sse_url, + token: stream_access.token, + })) +} + +async fn regenerate_response( + CurrentUser { user_id }: CurrentUser, + Path(session_id): Path, + Database(mut db): Database, + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let llm_provider = state + .provider_service() + .build_llm_provider(&mut db, &user_id, input.provider_id) + .await?; + let stream_access = state + .chat_service() + .regenerate_response( + &mut db, + user_id, + session_id, + input.provider_id, + llm_provider, + input.options, + ) + .await?; + + Ok(Json(StreamAccess { + url: stream_access.sse_url, + token: stream_access.token, + })) +} + +async fn connect_chat_stream( + CurrentUser { user_id }: CurrentUser, + Path(session_id): Path, + State(state): State, +) -> AppResult> { + let stream_access = state + .chat_service() + .connect_stream(&user_id, &session_id) + .await?; + + Ok(Json(StreamAccess { + url: stream_access.sse_url, + token: stream_access.token, + })) +} + +pub async fn cancel_chat_stream( + CurrentUser { user_id }: CurrentUser, + Path(session_id): Path, + State(state): State, +) -> AppResult<()> { + state + .chat_service() + .cancel_stream(&user_id, &session_id) + .await?; + + Ok(()) +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct PromptInput { + /// The prompt to send to the LLM provider + message: String, + /// The ID of the provider to chat with + provider_id: i32, + /// Configuration for the provider + options: LlmChatOptions, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ChatInput { + /// The new chat message from the user + message: Option, + /// The ID of the provider to chat with + provider_id: i32, + /// Configuration for the provider + options: LlmChatOptions, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct RegenerateInput { + /// The ID of the provider to chat with + provider_id: i32, + /// Configuration for the provider + options: LlmChatOptions, +} + +#[derive(Debug, JsonSchema, serde::Serialize)] +struct ActiveStreamsResponse { + /// The chat session IDs that have ongoing response streams + sessions: Vec, +} + +/// Access to an active streaming response +#[derive(Serialize, JsonSchema)] +struct StreamAccess { + /// URL to access the SSE stream + url: String, + /// Bearer token to access the SSE stream + token: String, +} diff --git a/server-new/src/api/health.rs b/server-new/src/api/health.rs new file mode 100644 index 0000000..a657c02 --- /dev/null +++ b/server-new/src/api/health.rs @@ -0,0 +1,13 @@ +use aide::axum::{ApiRouter, routing::get_with}; +use axum_aide_macros::handler_docs; + +use crate::state::AppState; + +pub fn routes() -> ApiRouter { + ApiRouter::new().api_route("/", get_with(health, health_docs)) +} + +#[handler_docs("Health route")] +async fn health() -> &'static str { + "OK" +} diff --git a/server-new/src/api/mod.rs b/server-new/src/api/mod.rs new file mode 100644 index 0000000..c0d4f56 --- /dev/null +++ b/server-new/src/api/mod.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use aide::{ + axum::ApiRouter, + openapi::{OpenApi, SecurityScheme, Server}, + swagger::Swagger, +}; +use axum::{Extension, extract::DefaultBodyLimit, routing::get}; +use axum_plugin::AdHocPlugin; +use strum::{Display, EnumIter, EnumMessage, IntoEnumIterator, IntoStaticStr}; + +use crate::{config::AppConfig, state::AppState}; + +mod api_key; +mod auth; +mod chat; +mod health; +mod provider; +mod session; +mod storage; +mod upload; + +const API_BASE: &str = "/api/v1"; +const API_AUTH_BASE: &str = "/api/v1/auth"; +pub const API_KEY_SCHEME: &str = "ApiKey"; + +/// API route tags for OpenAPI docs +#[derive(Display, IntoStaticStr, EnumMessage, EnumIter)] +enum ApiTag { + #[strum(message = "Manage API keys")] + ApiKey, + #[strum(message = "Authentication")] + Auth, + #[strum(message = "Chats and sessions")] + Chat, + #[strum(message = "AI / LLM Providers")] + Provider, + #[strum(message = "Files and attachments")] + Storage, +} + +/// Adds all API routes with OpenAPI docs to the server under `/api/v1` +pub fn plugin() -> AdHocPlugin { + AdHocPlugin::::named("API routes").on_setup(|app, router| { + let mut openapi = OpenApi::default(); + let api_routes = ApiRouter::new() + .nest("/api_key", api_key::routes()) + .nest( + "/auth", + auth::routes().layer(Extension(RoutePrefix(API_AUTH_BASE))), + ) + .nest("/chat", chat::routes()) + .nest("/health", health::routes()) + .nest("/provider", provider::routes()) + .nest("/session", session::routes()) + .nest("/storage", storage::routes()) + .nest( + "/upload", + upload::routes().layer(DefaultBodyLimit::max(app.config().security.upload_limit)), + ) + .finish_api_with(&mut openapi, build_openapi_doc) + .route( + "/docs/openapi.json", + get(async |Extension(openapi): Extension>| axum::Json(openapi)) + .layer(Extension(Arc::new(openapi))), + ) + .route( + "/docs", + get(Swagger::new(format!("{API_BASE}/docs/openapi.json")) + .with_title("RsChat API documentation") + .axum_handler()), + ); + + Ok(router.nest(API_BASE, api_routes)) + }) +} + +/// Extension to pass the route prefix to child routes +#[derive(Clone)] +struct RoutePrefix(&'static str); + +/// Build the OpenAPI docs +fn build_openapi_doc( + op: aide::transform::TransformOpenApi<'_>, +) -> aide::transform::TransformOpenApi<'_> { + let mut op = op + .title("RsChat API") + .description("OpenAPI specification for the RsChat server") + .server(Server { + url: String::from(API_BASE), + ..Default::default() + }) + .security_scheme( + API_KEY_SCHEME, + SecurityScheme::Http { + scheme: String::from("bearer"), + bearer_format: Some(String::from("bearer")), + description: Some(String::from("RsChat API key")), + extensions: Default::default(), + }, + ); + for tag in ApiTag::iter() { + op = op.tag(aide::openapi::Tag { + name: tag.to_string(), + description: tag.get_message().map(String::from), + ..Default::default() + }); + } + + op +} diff --git a/server-new/src/api/provider.rs b/server-new/src/api/provider.rs new file mode 100644 index 0000000..00ae962 --- /dev/null +++ b/server-new/src/api/provider.rs @@ -0,0 +1,93 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use axum_aide_macros::api_routes; + +use crate::{ + api::ApiTag, + db::models::ChatRsProvider, + error::AppResult, + extractors::{CurrentUser, Database}, + services::{ + model::types::LlmModel, + provider::types::{ProviderCreateInput, ProviderUpdateInput}, + }, + state::AppState, +}; + +api_routes! { + state: AppState, + tag: ApiTag::Provider.into(), + GET "/" => list_providers, "List providers"; + GET "/{provider_id}/models" => list_models, "List models"; + POST "/" => create_provider, "Create provider"; + PATCH "/{provider_id}" => update_provider, "Update provider"; + DELETE "/{provider_id}" => delete_provider, "Delete provider"; +} + +async fn list_providers( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, +) -> AppResult>> { + let providers = db.providers().list_by_user_id(&user_id).await?; + Ok(Json(providers)) +} + +async fn list_models( + CurrentUser { user_id }: CurrentUser, + Path(provider_id): Path, + Database(mut db): Database, + State(state): State, +) -> AppResult>> { + let (provider, provider_type, _) = state + .provider_service() + .get_provider(&mut db, &user_id, provider_id) + .await?; + let models = state + .model_service() + .list_models(&provider, &provider_type) + .await?; + + Ok(Json(models)) +} + +async fn create_provider( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + State(state): State, + Json(input): Json, +) -> AppResult> { + let provider = state + .provider_service() + .create_provider(&mut db, &user_id, &input) + .await?; + Ok(Json(provider)) +} + +async fn update_provider( + CurrentUser { user_id }: CurrentUser, + Path(provider_id): Path, + Database(mut db): Database, + State(state): State, + Json(input): Json, +) -> AppResult> { + let updated_provider = state + .provider_service() + .update_provider(&mut db, &user_id, provider_id, &input) + .await?; + Ok(Json(updated_provider)) +} + +async fn delete_provider( + CurrentUser { user_id }: CurrentUser, + Path(provider_id): Path, + Database(mut db): Database, + State(state): State, +) -> AppResult> { + let deleted_provider = state + .provider_service() + .delete_provider(&mut db, &user_id, provider_id) + .await?; + Ok(Json(deleted_provider)) +} diff --git a/server-new/src/api/session.rs b/server-new/src/api/session.rs new file mode 100644 index 0000000..d20346a --- /dev/null +++ b/server-new/src/api/session.rs @@ -0,0 +1,179 @@ +use std::borrow::Cow; + +use axum::{ + Json, + extract::{Path, Query}, +}; +use axum_aide_macros::api_routes; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + api::ApiTag, + db::{ + models::{ + ChatRsLogLlmRequest, ChatRsMessage, ChatRsSession, NewChatRsSession, + UpdateChatRsSession, + }, + queries::FullTextSearchResult, + }, + error::{AppError, AppResult}, + extractors::{CurrentUser, Database}, + services::chat::DEFAULT_SESSION_TITLE, + state::AppState, +}; + +api_routes! { + state: AppState, + tag: ApiTag::Chat.into(), + GET "/" => get_recent_sessions, "List recent chat sessions"; + POST "/" => create_session, "Create chat session"; + GET "/{session_id}" => get_session, "Get chat session"; + GET "/search" => search_sessions, "Search chat sessions"; + PATCH "/{session_id}" => update_session, "Update chat session"; + DELETE "/{session_id}" => delete_session, "Delete chat session"; + DELETE "/{session_id}/{message_id}" => delete_message, "Delete chat message"; +} + +async fn get_recent_sessions( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, +) -> AppResult>> { + let sessions = db.chats().list_recent_sessions(&user_id).await?; + + Ok(Json(sessions)) +} + +async fn create_session( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, +) -> AppResult> { + let session_id = db + .chats() + .create_session(NewChatRsSession { + user_id: &user_id, + title: DEFAULT_SESSION_TITLE, + }) + .await?; + + Ok(Json(SessionIdResponse { session_id })) +} + +async fn get_session( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + Path(session_id): Path, +) -> AppResult> { + let session = db + .chats() + .find_session(&user_id, &session_id) + .await? + .ok_or_else(|| AppError::not_found("chat session not found"))?; + let messages = db + .chats() + .list_messages_with_logs(&session_id) + .await? + .into_iter() + .map(|(message, llm_request)| SessionMessage { + message, + llm_request, + }) + .collect(); + + Ok(Json(GetSessionResponse { session, messages })) +} + +async fn search_sessions( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + Query(SessionSearchQuery { query }): Query>, +) -> AppResult>> { + let sessions = db.chats().search_sessions(&user_id, &query).await?; + + Ok(Json(sessions)) +} + +async fn update_session( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + Path(session_id): Path, + Json(input): Json, +) -> AppResult> { + let updated_id = db + .chats() + .update_session( + &user_id, + &session_id, + UpdateChatRsSession { + title: Some(&input.title), + ..Default::default() + }, + ) + .await?; + + match updated_id { + Some(session_id) => Ok(Json(SessionIdResponse { session_id })), + None => Err(AppError::not_found("chat session not found")), + } +} + +async fn delete_session( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + Path(session_id): Path, +) -> AppResult> { + match db.chats().delete_session(&user_id, &session_id).await? { + Some(session_id) => Ok(Json(SessionIdResponse { session_id })), + None => Err(AppError::not_found("chat session not found")), + } +} + +async fn delete_message( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, + Path((session_id, message_id)): Path<(Uuid, Uuid)>, +) -> AppResult> { + match db.chats().find_session(&user_id, &session_id).await? { + Some(session) => match db.chats().delete_message(&session.id, &message_id).await? { + Some(message_id) => Ok(Json(MessageIdResponse { message_id })), + None => Err(AppError::not_found("chat message not found")), + }, + None => Err(AppError::not_found("chat session not found")), + } +} + +#[derive(Serialize, JsonSchema)] +struct SessionIdResponse { + session_id: Uuid, +} + +#[derive(Serialize, JsonSchema)] +struct MessageIdResponse { + message_id: Uuid, +} + +#[derive(Serialize, JsonSchema)] +struct GetSessionResponse { + session: ChatRsSession, + messages: Vec, +} + +#[derive(Serialize, JsonSchema)] +struct SessionMessage { + /// The message + message: ChatRsMessage, + /// Request metadata for assistant responses + #[serde(skip_serializing_if = "Option::is_none")] + llm_request: Option, +} + +#[derive(Deserialize, JsonSchema)] +struct SessionSearchQuery<'q> { + query: Cow<'q, str>, +} + +#[derive(Deserialize, JsonSchema)] +struct UpdateSessionInput { + title: String, +} diff --git a/server-new/src/api/storage.rs b/server-new/src/api/storage.rs new file mode 100644 index 0000000..b057a4b --- /dev/null +++ b/server-new/src/api/storage.rs @@ -0,0 +1,86 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use axum_aide_macros::api_routes; +use schemars::JsonSchema; +use serde::Serialize; +use uuid::Uuid; + +use crate::{ + api::ApiTag, + db::models::{ChatRsFile, ChatRsMessageAttachment}, + error::AppResult, + extractors::{CurrentUser, Database}, + state::AppState, +}; + +api_routes! { + state: AppState, + tag: ApiTag::Storage.into(), + GET "/user" => list_user_files, "List user files"; + DELETE "/user/{file_id}" => delete_user_file, "Delete user file"; + GET "/session/{session_id}" => list_session_files, "List session files"; + DELETE "/session/{session_id}/{file_id}" => delete_session_file, "Delete session file"; +} + +async fn list_user_files( + CurrentUser { user_id }: CurrentUser, + Database(mut db): Database, +) -> AppResult>> { + let files = db.files().list_user_files(&user_id).await?; + + Ok(Json(files)) +} + +async fn list_session_files( + CurrentUser { user_id }: CurrentUser, + Path(session_id): Path, + Database(mut db): Database, +) -> AppResult> { + let (files, attachments) = db + .files() + .list_session_files_and_attachments(&user_id, &session_id) + .await?; + + Ok(Json(SessionFilesAndAttachments { files, attachments })) +} + +async fn delete_user_file( + CurrentUser { user_id }: CurrentUser, + Path(file_id): Path, + Database(mut db): Database, + State(state): State, +) -> AppResult> { + let file_id = state + .storage_service() + .delete_file(&mut db, &user_id, None, &file_id) + .await?; + + Ok(Json(FileIdResponse { file_id })) +} + +async fn delete_session_file( + CurrentUser { user_id }: CurrentUser, + Path((session_id, file_id)): Path<(Uuid, Uuid)>, + Database(mut db): Database, + State(state): State, +) -> AppResult> { + let file_id = state + .storage_service() + .delete_file(&mut db, &user_id, Some(&session_id), &file_id) + .await?; + + Ok(Json(FileIdResponse { file_id })) +} + +#[derive(Serialize, JsonSchema)] +struct SessionFilesAndAttachments { + files: Vec, + attachments: Vec, +} + +#[derive(Serialize, JsonSchema)] +struct FileIdResponse { + file_id: Uuid, +} diff --git a/server-new/src/api/upload.rs b/server-new/src/api/upload.rs new file mode 100644 index 0000000..86e9c69 --- /dev/null +++ b/server-new/src/api/upload.rs @@ -0,0 +1,75 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use axum_aide_macros::api_routes; +use uuid::Uuid; + +use crate::{ + api::ApiTag, + db::models::ChatRsFile, + error::{AppError, AppResult}, + extractors::{CurrentUser, Database, FileUpload}, + state::AppState, +}; + +api_routes! { + state: AppState, + tag: ApiTag::Storage.into(), + POST "/user/{*file_path}" => upload_user_file, "Upload user file" { + description: "Upload a file to the user account. The file must be the only field in the form, + with a supported content type." + }; + POST "/session/{session_id}/{*file_path}" => upload_session_file, "Upload session file" { + description: "Upload a file to a chat session. The file must be the only field in the form, + with a supported content type." + }; +} + +async fn upload_user_file( + CurrentUser { user_id }: CurrentUser, + Path(path): Path, + State(state): State, + upload: FileUpload, +) -> AppResult> { + let file = state + .storage_service() + .create_file( + &user_id, + None, + &path, + upload.size(), + &upload.content_type(), + upload.into_stream(), + ) + .await?; + + Ok(Json(file)) +} + +async fn upload_session_file( + CurrentUser { user_id }: CurrentUser, + Path((sess_id, path)): Path<(Uuid, String)>, + Database(mut db): Database, + State(state): State, + upload: FileUpload, +) -> AppResult> { + if db.chats().find_session(&user_id, &sess_id).await?.is_none() { + return Err(AppError::not_found("session not found")); + } + drop(db); // free database connection since this could be long-running request + + let file = state + .storage_service() + .create_file( + &user_id, + Some(&sess_id), + &path, + upload.size(), + &upload.content_type(), + upload.into_stream(), + ) + .await?; + + Ok(Json(file)) +} diff --git a/server-new/src/config.rs b/server-new/src/config.rs new file mode 100644 index 0000000..a968025 --- /dev/null +++ b/server-new/src/config.rs @@ -0,0 +1,153 @@ +use std::{net::IpAddr, path::PathBuf}; + +use axum_plugin::figment::{ + Figment, + providers::{Env, Format, Serialized, Toml}, +}; +use serde::{Deserialize, Serialize}; + +use crate::services::{ + auth::{ + oauth::{DiscordOAuthConfig, GitHubOAuthConfig, GoogleOAuthConfig, OidcConfig}, + proxy::ProxyHeaderConfig, + }, + storage::engines::S3Config, +}; + +/// Extract configuration from defaults, local `config.toml`, then `RS_CHAT_` environment variables split by `__`. +/// See https://docs.rs/figment/latest/figment/index.html#for-application-authors +pub fn figment() -> Figment { + Figment::from(Serialized::defaults(AppConfig::default())) + .merge(Toml::file("config.toml")) + .merge(Env::prefixed("RS_CHAT_").split("__")) +} + +/// Parsed app configuration +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub server: ServerConfig, + pub database: DatabaseConfig, + pub auth: AuthConfig, + pub services: ServiceConfig, + pub security: SecurityConfig, + pub redis: RedisConfig, + pub storage: StorageConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub host: IpAddr, + pub port: u16, + pub base_url: String, + pub log_level: String, + pub data_dir: PathBuf, + pub web_root: String, + pub request_id_header: String, + pub ip_header: Option, +} +impl Default for ServerConfig { + fn default() -> Self { + Self { + host: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + port: 8080, + base_url: String::from("http://localhost:8080"), + log_level: String::from("info"), + data_dir: PathBuf::from("/data"), + web_root: String::from("../web/dist"), + request_id_header: String::from("x-request-id"), + ip_header: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseConfig { + pub url: String, +} +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + url: String::from("postgres://localhost:5432"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceConfig { + pub streamer_url: String, + pub streamer_api_key: String, +} +impl Default for ServiceConfig { + fn default() -> Self { + Self { + streamer_url: String::from("http://localhost:8081"), + streamer_api_key: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthConfig { + pub encryption_key: String, + pub cookie_name: String, + pub session_length: i64, + pub github: Option, + pub discord: Option, + pub google: Option, + pub oidc: Option, + pub proxy: ProxyHeaderConfig, +} +impl Default for AuthConfig { + fn default() -> Self { + Self { + encryption_key: String::new(), + cookie_name: String::from("auth-rs-chat"), + session_length: 604800, // 1 week in seconds + github: None, + discord: None, + google: None, + oidc: None, + proxy: ProxyHeaderConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityConfig { + pub body_limit: usize, + pub upload_limit: usize, + pub request_timeout: u64, +} +impl Default for SecurityConfig { + fn default() -> Self { + Self { + body_limit: 1 * 1024 * 1024, // 1 MB + upload_limit: 5 * 1024 * 1024, // 5 MB + request_timeout: 120, // 2 minutes + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedisConfig { + pub url: String, + pub pool_size: usize, + pub timeout: u64, +} +impl Default for RedisConfig { + fn default() -> Self { + Self { + url: String::from("redis://localhost:6379"), + pool_size: 4, + timeout: 10, // 10 seconds + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(tag = "engine", rename_all = "lowercase")] +pub enum StorageConfig { + #[default] + Local, + S3(S3Config), +} diff --git a/server-new/src/db/mod.rs b/server-new/src/db/mod.rs new file mode 100644 index 0000000..f4230c9 --- /dev/null +++ b/server-new/src/db/mod.rs @@ -0,0 +1,82 @@ +//! Database operations + +use std::ops::{Deref, DerefMut}; + +use diesel_async::{ + AsyncPgConnection, + pooled_connection::deadpool::{Object, Pool, PoolError}, +}; + +pub mod models; +pub mod queries; +pub mod repositories; +mod schema; + +/// Type of the database pool +pub type DbPool = Pool; +/// Error when attempting to retrieve a connection from the pool +pub type DbPoolError = PoolError; + +/// The database connection retrieved from the pool. For pipelining multiple +/// queries in Diesel, a shared reference can be used with `&mut conn.as_ref()`. +pub struct DbConnection(Object); +impl Deref for DbConnection { + type Target = AsyncPgConnection; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for DbConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} +impl AsRef for DbConnection { + fn as_ref(&self) -> &AsyncPgConnection { + &**self + } +} + +/// Date/time format used in all database tables +pub type UtcDateTime = chrono::DateTime; + +/// Wrapper around a database connection that gives access to the repositories, +/// e.g. `UserRepository`, `ChatRepository`, etc. +pub struct DbService { + cxn: DbConnection, +} + +impl DbService { + pub fn new(cxn: DbConnection) -> Self { + Self { cxn } + } + pub async fn from_pool(pool: &DbPool) -> Result { + let cxn = pool.get().await?; + Ok(Self::new(DbConnection(cxn))) + } + + pub fn api_keys(&mut self) -> repositories::ApiKeyRepository<'_> { + repositories::ApiKeyRepository::new(&mut self.cxn) + } + pub fn auth_sessions(&mut self) -> repositories::SessionRepository<'_> { + repositories::SessionRepository::new(&mut self.cxn) + } + pub fn chats(&mut self) -> repositories::ChatRepository<'_> { + repositories::ChatRepository::new(&mut self.cxn) + } + pub fn files(&mut self) -> repositories::FileRepository<'_> { + repositories::FileRepository::new(&mut self.cxn) + } + pub fn logs(&mut self) -> repositories::LogRepository<'_> { + repositories::LogRepository::new(&mut self.cxn) + } + pub fn providers(&mut self) -> repositories::ProviderRepository<'_> { + repositories::ProviderRepository::new(&mut self.cxn) + } + pub fn secrets(&mut self) -> repositories::SecretRepository<'_> { + repositories::SecretRepository::new(&mut self.cxn) + } + pub fn users(&mut self) -> repositories::UserRepository<'_> { + repositories::UserRepository::new(&mut self.cxn) + } +} diff --git a/server-new/src/db/models.rs b/server-new/src/db/models.rs new file mode 100644 index 0000000..ac65177 --- /dev/null +++ b/server-new/src/db/models.rs @@ -0,0 +1,23 @@ +//! Database models + +use crate::db::schema; + +mod api_key; +mod chat; +mod file; +mod log; +mod provider; +mod secret; +mod session; +// mod tool; +mod user; + +pub use api_key::*; +pub use chat::*; +pub use file::*; +pub use log::*; +pub use provider::*; +pub use secret::*; +// pub use tool::*; +pub use session::*; +pub use user::*; diff --git a/server-new/src/db/models/api_key.rs b/server-new/src/db/models/api_key.rs new file mode 100644 index 0000000..ad9213a --- /dev/null +++ b/server-new/src/db/models/api_key.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use schemars::JsonSchema; +use serde::Serialize; +use uuid::Uuid; + +use crate::db::models::ChatRsUser; + +#[derive(Identifiable, Queryable, Selectable, Associations, Serialize, JsonSchema)] +#[diesel(belongs_to(ChatRsUser, foreign_key = user_id))] +#[diesel(table_name = super::schema::app_api_keys)] +pub struct ChatRsApiKey { + pub id: Uuid, + #[serde(skip)] + pub user_id: Uuid, + pub name: String, + pub created_at: DateTime, +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::app_api_keys)] +pub struct NewChatRsApiKey<'r> { + pub user_id: &'r Uuid, + pub name: &'r str, +} diff --git a/server-new/src/db/models/chat.rs b/server-new/src/db/models/chat.rs new file mode 100644 index 0000000..d7609ba --- /dev/null +++ b/server-new/src/db/models/chat.rs @@ -0,0 +1,125 @@ +use chrono::{DateTime, Utc}; +use diesel::{deserialize::FromSqlRow, expression::AsExpression, prelude::*}; +use diesel_jsonb_derive::AsJsonb; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::db::models::ChatRsUser; + +#[derive(Identifiable, Associations, Queryable, Selectable, Serialize, JsonSchema)] +#[diesel(belongs_to(ChatRsUser, foreign_key = user_id))] +#[diesel(table_name = super::schema::chat_sessions)] +pub struct ChatRsSession { + pub id: Uuid, + #[serde(skip)] + pub user_id: Uuid, + pub title: String, + pub meta: ChatRsSessionMeta, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema, FromSqlRow, AsExpression, AsJsonb)] +#[diesel(sql_type = diesel::sql_types::Jsonb)] +pub struct ChatRsSessionMeta { + // /// User configuration of tools for this session + // #[serde(skip_serializing_if = "Option::is_none")] + // pub tool_config: Option, +} +impl ChatRsSessionMeta { + pub fn new() -> Self { + Self {} + } +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::chat_sessions)] +pub struct NewChatRsSession<'r> { + pub user_id: &'r Uuid, + pub title: &'r str, +} + +#[derive(AsChangeset, Default)] +#[diesel(table_name = super::schema::chat_sessions)] +pub struct UpdateChatRsSession<'r> { + pub title: Option<&'r str>, + pub meta: Option, +} + +#[derive(diesel_derive_enum::DbEnum)] +#[db_enum(existing_type_path = "crate::db::schema::sql_types::ChatMessageRole")] +#[derive(Debug, strum::EnumIs, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ChatRsMessageRole { + User, + Assistant, + System, + Tool, +} + +#[derive(Identifiable, Queryable, Selectable, Associations, Serialize, JsonSchema)] +#[diesel(belongs_to(ChatRsSession, foreign_key = session_id))] +#[diesel(table_name = super::schema::chat_messages)] +pub struct ChatRsMessage { + pub id: Uuid, + pub session_id: Uuid, + pub role: ChatRsMessageRole, + pub content: String, + pub meta: ChatRsMessageMeta, + pub created_at: DateTime, +} + +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema, AsExpression, FromSqlRow, AsJsonb)] +#[diesel(sql_type = diesel::sql_types::Jsonb)] +pub struct ChatRsMessageMeta { + /// User messages: metadata associated with the user message + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + /// Assistant messages: metadata associated with the assistant message + #[serde(skip_serializing_if = "Option::is_none")] + pub assistant: Option, + // /// Tool messages: metadata of the executed tool call + // #[serde(skip_serializing_if = "Option::is_none")] + // pub tool_call: Option, +} +impl ChatRsMessageMeta { + pub fn new_assistant(assistant_meta: AssistantMeta) -> Self { + Self { + assistant: Some(assistant_meta), + ..Default::default() + } + } + pub fn new_user(user_meta: UserMeta) -> Self { + Self { + user: Some(user_meta), + ..Default::default() + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct UserMeta { + /// The IDs of the files attached to this message + #[serde(skip_serializing_if = "Option::is_none")] + pub files: Option>, +} + +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct AssistantMeta { + // /// The tool calls requested by the assistant + // #[serde(skip_serializing_if = "Option::is_none")] + // pub tool_calls: Option>, + /// IDs of generated files + #[serde(skip_serializing_if = "Option::is_none")] + pub files: Option>, +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::chat_messages)] +pub struct NewChatRsMessage<'r> { + pub session_id: &'r Uuid, + pub role: ChatRsMessageRole, + pub content: &'r str, + pub meta: ChatRsMessageMeta, +} diff --git a/server-new/src/db/models/file.rs b/server-new/src/db/models/file.rs new file mode 100644 index 0000000..670967c --- /dev/null +++ b/server-new/src/db/models/file.rs @@ -0,0 +1,66 @@ +use diesel::prelude::*; +use schemars::JsonSchema; +use serde::Serialize; +use strum::{AsRefStr, EnumString}; +use uuid::Uuid; + +use crate::db::{ + UtcDateTime, + models::{ChatRsMessage, ChatRsUser}, +}; + +#[derive(Identifiable, Associations, Queryable, Selectable, Serialize, JsonSchema)] +#[diesel(belongs_to(ChatRsUser, foreign_key = user_id))] +#[diesel(table_name = super::schema::files)] +pub struct ChatRsFile { + pub id: Uuid, + #[serde(skip)] + pub user_id: Uuid, + pub session_id: Option, + pub path: String, + #[schemars(with = "ChatRsFileType")] + pub file_type: String, + pub content_type: String, + pub size: i32, + pub created_at: UtcDateTime, + #[serde(skip)] + pub updated_at: UtcDateTime, +} + +#[derive(Identifiable, Selectable, Queryable, Associations, Serialize, JsonSchema)] +#[diesel(belongs_to(ChatRsMessage, foreign_key = message_id))] +#[diesel(belongs_to(ChatRsFile, foreign_key = file_id))] +#[diesel(table_name = super::schema::message_attachments)] +#[diesel(primary_key(message_id, file_id))] +pub struct ChatRsMessageAttachment { + pub message_id: Uuid, + pub file_id: Uuid, +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::files)] +pub struct NewChatRsFile<'r> { + pub user_id: &'r Uuid, + pub session_id: Option<&'r Uuid>, + pub path: &'r str, + pub file_type: &'r str, + pub content_type: &'r str, + pub size: i32, +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::message_attachments)] +pub struct NewChatRsMessageAttachment<'r> { + pub message_id: &'r Uuid, + pub file_id: &'r Uuid, +} + +/// File modality +#[derive(Debug, PartialEq, Eq, Hash, EnumString, AsRefStr, JsonSchema)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ChatRsFileType { + Text, + Image, + Pdf, +} diff --git a/server-new/src/db/models/log.rs b/server-new/src/db/models/log.rs new file mode 100644 index 0000000..01c227a --- /dev/null +++ b/server-new/src/db/models/log.rs @@ -0,0 +1,119 @@ +use bigdecimal::BigDecimal; +use diesel::{deserialize::FromSqlRow, expression::AsExpression, prelude::*}; +use diesel_jsonb_derive::AsJsonb; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use strum::{AsRefStr, EnumString}; +use uuid::Uuid; + +use crate::db::{ + UtcDateTime, + models::{ChatRsMessage, ChatRsUser}, +}; + +#[derive(Identifiable, Associations, Queryable, Selectable, AsChangeset)] +#[diesel(belongs_to(ChatRsUser, foreign_key = user_id))] +#[diesel(belongs_to(ChatRsMessage, foreign_key = message_id))] +#[diesel(table_name = super::schema::llm_logs)] +pub struct ChatRsLog { + pub id: i32, + pub kind: String, + pub user_id: Uuid, + pub provider_id: Option, + pub session_id: Option, + pub message_id: Option, + pub model: String, + pub input_tokens: Option, + pub output_tokens: Option, + pub cost: Option, + pub ttft_ms: Option, + pub status: String, + pub meta: ChatRsLogMeta, + pub started_at: UtcDateTime, + pub completed_at: Option, +} + +#[skip_serializing_none] +#[derive(Identifiable, Queryable, Selectable, Serialize, JsonSchema)] +#[diesel(table_name = super::schema::llm_logs)] +pub struct ChatRsLogLlmRequest { + #[serde(skip)] + pub id: i32, + pub provider_id: Option, + pub model: String, + pub input_tokens: Option, + pub output_tokens: Option, + pub cost: Option, + #[schemars(with = "ChatRsLogStatus")] + pub status: String, + pub meta: ChatRsLogMeta, +} + +#[derive(Debug, Default, Clone, Copy, EnumString, AsRefStr, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ChatRsLogKind { + Chat, + Title, + #[default] + Prompt, + Image, +} + +#[derive(Debug, Default, Clone, Copy, EnumString, AsRefStr, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ChatRsLogStatus { + Started, + Cancelled, + Error, + #[default] + Completed, +} + +#[skip_serializing_none] +#[derive(Debug, Default, Serialize, Deserialize, FromSqlRow, AsExpression, AsJsonb, JsonSchema)] +#[diesel(sql_type = diesel::sql_types::Jsonb)] +pub struct ChatRsLogMeta { + /// Options passed to the LLM provider + pub options: Option, + /// Any errors received from the LLM provider + pub errors: Option>, + /// The request ID at the LLM provider + pub request_id: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct ChatRsLogMetaOptions { + pub temperature: Option, + pub max_tokens: Option, +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::llm_logs)] +pub struct NewChatRsLog<'a> { + pub kind: &'a str, + pub user_id: &'a Uuid, + pub provider_id: i32, + pub session_id: Option<&'a Uuid>, + pub model: &'a str, + pub status: &'a str, + pub meta: Option<&'a ChatRsLogMeta>, + pub started_at: UtcDateTime, +} + +#[derive(Default, Identifiable, Queryable, Selectable, AsChangeset)] +#[diesel(table_name = super::schema::llm_logs)] +pub struct UpdateChatRsLog { + pub id: i32, + pub message_id: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub ttft_ms: Option, + pub cost: Option, + pub status: String, + pub meta: ChatRsLogMeta, + pub completed_at: Option, +} diff --git a/server-new/src/db/models/provider.rs b/server-new/src/db/models/provider.rs new file mode 100644 index 0000000..90d7ab8 --- /dev/null +++ b/server-new/src/db/models/provider.rs @@ -0,0 +1,74 @@ +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use strum::{EnumString, IntoStaticStr}; +use uuid::Uuid; + +use crate::db::models::ChatRsUser; + +#[derive(Identifiable, Associations, Queryable, Selectable, Serialize, JsonSchema)] +#[diesel(belongs_to(ChatRsUser, foreign_key = user_id))] +#[diesel(table_name = super::schema::providers)] +pub struct ChatRsProvider { + pub id: i32, + pub name: String, + #[schemars(with = "ChatRsProviderType")] + pub provider_type: String, + #[schemars(with = "OpenAISubtype")] + pub openai_subtype: Option, + #[serde(skip)] + pub user_id: Uuid, + pub default_model: String, + pub base_url: Option, + pub api_key_id: Option, + pub created_at: DateTime, +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::providers)] +pub struct NewChatRsProvider<'a> { + pub name: &'a str, + pub provider_type: &'a str, + pub openai_subtype: Option<&'a str>, + pub user_id: &'a Uuid, + pub base_url: Option<&'a str>, + pub default_model: &'a str, + pub api_key_id: Option, +} + +#[derive(Default, AsChangeset)] +#[diesel(table_name = super::schema::providers)] +pub struct UpdateChatRsProvider<'a> { + pub name: Option<&'a str>, + pub base_url: Option<&'a str>, + pub default_model: Option<&'a str>, + pub api_key_id: Option, +} + +/// The API type of the provider +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ChatRsProviderType { + /// OpenAI or OpenAI-compatible provider + OpenAI, + /// Anthropic provider + Anthropic, + /// Ollama provider + Ollama, + /// Lorem ipsum provider (for testing) + Lorem, +} + +/// The subtype for OpenAI-compatible providers +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr, Deserialize, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum OpenAISubtype { + #[default] + OpenAI, + OpenRouter, +} diff --git a/server-new/src/db/models/secret.rs b/server-new/src/db/models/secret.rs new file mode 100644 index 0000000..dec5164 --- /dev/null +++ b/server-new/src/db/models/secret.rs @@ -0,0 +1,46 @@ +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::Serialize; +use uuid::Uuid; + +use crate::db::models::ChatRsUser; + +#[derive(Identifiable, Queryable, Selectable, Associations)] +#[diesel(belongs_to(ChatRsUser, foreign_key = user_id))] +#[diesel(table_name = super::schema::secrets)] +pub struct ChatRsSecret { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub ciphertext: Vec, + pub nonce: Vec, + pub created_at: DateTime, +} + +#[derive(Identifiable, Queryable, Selectable, Associations, Serialize)] +#[diesel(belongs_to(ChatRsUser, foreign_key = user_id))] +#[diesel(table_name = super::schema::secrets)] +pub struct ChatRsSecretMeta { + pub id: Uuid, + #[serde(skip)] + pub user_id: Uuid, + pub name: String, + pub created_at: DateTime, +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::secrets)] +pub struct NewChatRsSecret<'r> { + pub user_id: &'r Uuid, + pub name: &'r str, + pub ciphertext: &'r Vec, + pub nonce: &'r Vec, +} + +#[derive(Default, AsChangeset)] +#[diesel(table_name = super::schema::secrets)] +pub struct UpdateChatRsSecret<'r> { + pub name: Option<&'r str>, + pub ciphertext: Option<&'r Vec>, + pub nonce: Option<&'r Vec>, +} diff --git a/server-new/src/db/models/session.rs b/server-new/src/db/models/session.rs new file mode 100644 index 0000000..fa813e4 --- /dev/null +++ b/server-new/src/db/models/session.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; + +use diesel::{deserialize::FromSqlRow, expression::AsExpression, prelude::*}; +use diesel_jsonb_derive::AsJsonb; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::db::{UtcDateTime, models::ChatRsUser}; + +#[derive(Identifiable, Associations, Queryable, Selectable, Serialize, JsonSchema)] +#[diesel(belongs_to(ChatRsUser, foreign_key = user_id))] +#[diesel(table_name = super::schema::auth_sessions)] +pub struct ChatRsAuthSession { + pub id: Uuid, + #[serde(skip)] + pub user_id: Option, + pub data: AuthSessionData, + pub expires_at: UtcDateTime, +} + +#[derive(Insertable)] +#[diesel(table_name = super::schema::auth_sessions)] +pub struct NewChatRsAuthSession<'r> { + pub id: &'r Uuid, + pub user_id: Option<&'r Uuid>, + pub data: AuthSessionData, + pub expires_at: UtcDateTime, +} + +#[derive(AsChangeset)] +#[diesel(table_name = super::schema::auth_sessions)] +pub struct UpdateChatRsAuthSession { + pub data: AuthSessionData, + pub expires_at: UtcDateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromSqlRow, AsExpression, AsJsonb, JsonSchema)] +#[diesel(sql_type = diesel::sql_types::Jsonb)] +pub struct AuthSessionData(pub HashMap); diff --git a/server-new/src/db/models/user.rs b/server-new/src/db/models/user.rs new file mode 100644 index 0000000..bb47d5d --- /dev/null +++ b/server-new/src/db/models/user.rs @@ -0,0 +1,45 @@ +use diesel::prelude::*; +use schemars::JsonSchema; +use serde::Serialize; +use serde_with::skip_serializing_none; +use uuid::Uuid; + +#[skip_serializing_none] +#[derive(Identifiable, Queryable, Selectable, Serialize, JsonSchema)] +#[diesel(table_name = super::schema::users)] +pub struct ChatRsUser { + pub id: Uuid, + pub name: String, + pub avatar_url: Option, + pub github_id: Option, + pub google_id: Option, + pub discord_id: Option, + pub oidc_id: Option, + pub sso_username: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Insertable, Default)] +#[diesel(table_name = super::schema::users)] +pub struct NewChatRsUser<'r> { + pub github_id: Option<&'r str>, + pub google_id: Option<&'r str>, + pub discord_id: Option<&'r str>, + pub oidc_id: Option<&'r str>, + pub sso_username: Option<&'r str>, + pub name: &'r str, + pub avatar_url: Option<&'r str>, +} + +#[derive(AsChangeset, Default)] +#[diesel(table_name = super::schema::users)] +pub struct UpdateChatRsUser<'r> { + pub github_id: Option<&'r str>, + pub google_id: Option<&'r str>, + pub discord_id: Option<&'r str>, + pub oidc_id: Option<&'r str>, + pub sso_username: Option<&'r str>, + pub name: Option<&'r str>, + pub avatar_url: Option<&'r str>, +} diff --git a/server-new/src/db/queries.rs b/server-new/src/db/queries.rs new file mode 100644 index 0000000..2bdd408 --- /dev/null +++ b/server-new/src/db/queries.rs @@ -0,0 +1,76 @@ +use diesel::{prelude::QueryableByName, sql_query}; +use diesel_async::RunQueryDsl; +use schemars::JsonSchema; +use serde::Serialize; +use uuid::Uuid; + +use crate::db::DbConnection; + +/// Session matches for a full-text search query of chat titles and messages +#[derive(Debug, Clone, QueryableByName, Serialize, JsonSchema)] +pub struct FullTextSearchResult { + #[diesel(sql_type = diesel::sql_types::Uuid)] + pub session_id: Uuid, + #[diesel(sql_type = diesel::sql_types::Double)] + pub session_rank: f64, + #[diesel(sql_type = diesel::sql_types::Timestamptz)] + pub session_updated_at: chrono::DateTime, + #[diesel(sql_type = diesel::sql_types::BigInt)] + pub message_matches: i64, + #[diesel(sql_type = diesel::sql_types::Text)] + pub title_highlight: String, + #[diesel(sql_type = diesel::sql_types::Text)] + pub message_highlights: String, +} + +/// Performs a full-text search of user's chat titles and messages +pub async fn full_text_query( + conn: &mut DbConnection, + user_id: &Uuid, + query: &str, + limit: i32, +) -> Result, diesel::result::Error> { + let results: Vec = sql_query( + r#" + WITH search_query AS ( + SELECT plainto_tsquery('english', $1) AS query + ), + message_stats AS ( + SELECT + cm.session_id, + cs.title, + cs.updated_at, + cm.content, + ts_rank(cm.search_vector, sq.query) AS rank, + COUNT(*) OVER (PARTITION BY cm.session_id) AS message_matches, + ROW_NUMBER() OVER ( + PARTITION BY cm.session_id + ORDER BY ts_rank(cm.search_vector, sq.query) DESC + ) AS rank_in_session + FROM chat_messages cm + JOIN chat_sessions cs ON cm.session_id = cs.id + CROSS JOIN search_query sq + WHERE cm.search_vector @@ sq.query + AND cs.user_id = $2 + ) + SELECT + session_id, + rank * (1 + LOG(message_matches) * 0.1) AS session_rank, + updated_at AS session_updated_at, + message_matches, + ts_headline('english', title, sq.query, 'StartSel=§§§HIGHLIGHT_START§§§, StopSel=§§§HIGHLIGHT_END§§§, HighlightAll=true') AS title_highlight, + ts_headline('english', content, sq.query, 'StartSel=§§§HIGHLIGHT_START§§§, StopSel=§§§HIGHLIGHT_END§§§, MinWords=8, MaxWords=12, MaxFragments=3') AS message_highlights + FROM message_stats ms + CROSS JOIN search_query sq + WHERE rank_in_session = 1 -- Only best message per session + ORDER BY session_rank DESC + LIMIT $3; + "#, + ) + .bind::(query) + .bind::(user_id) + .bind::(limit) + .load(conn).await?; + + Ok(results) +} diff --git a/server-new/src/db/repositories.rs b/server-new/src/db/repositories.rs new file mode 100644 index 0000000..2698803 --- /dev/null +++ b/server-new/src/db/repositories.rs @@ -0,0 +1,19 @@ +//! Database repositories + +mod api_key; +mod chat; +mod file; +mod log; +mod provider; +mod secret; +mod session; +mod user; + +pub use api_key::ApiKeyRepository; +pub use chat::ChatRepository; +pub use file::FileRepository; +pub use log::{LlmLogComplete, LlmLogCreate, LogRepository}; +pub use provider::ProviderRepository; +pub use secret::SecretRepository; +pub use session::SessionRepository; +pub use user::UserRepository; diff --git a/server-new/src/db/repositories/api_key.rs b/server-new/src/db/repositories/api_key.rs new file mode 100644 index 0000000..239a3a4 --- /dev/null +++ b/server-new/src/db/repositories/api_key.rs @@ -0,0 +1,69 @@ +use diesel::prelude::*; +use diesel::result::Error; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::db::{ + DbConnection, + models::{ChatRsApiKey, NewChatRsApiKey}, + schema::app_api_keys, +}; + +pub struct ApiKeyRepository<'a> { + pub db: &'a mut DbConnection, +} + +impl<'a> ApiKeyRepository<'a> { + pub fn new(db: &'a mut DbConnection) -> Self { + ApiKeyRepository { db } + } + + pub async fn find_by_id(&mut self, id: &Uuid) -> Result, Error> { + app_api_keys::table + .find(id) + .select(ChatRsApiKey::as_select()) + .first(self.db) + .await + .optional() + } + + pub async fn find_by_user_id(&mut self, user_id: &Uuid) -> Result, Error> { + let keys = app_api_keys::table + .filter(app_api_keys::user_id.eq(user_id)) + .select(ChatRsApiKey::as_select()) + .load(self.db) + .await?; + + Ok(keys) + } + + pub async fn create(&mut self, api_key: NewChatRsApiKey<'_>) -> Result { + diesel::insert_into(app_api_keys::table) + .values(api_key) + .returning(app_api_keys::id) + .get_result(self.db) + .await + } + + pub async fn delete( + &mut self, + user_id: &Uuid, + api_key_id: &Uuid, + ) -> Result, Error> { + diesel::delete(app_api_keys::table) + .filter(app_api_keys::id.eq(api_key_id)) + .filter(app_api_keys::user_id.eq(user_id)) + .returning(app_api_keys::id) + .get_result(self.db) + .await + .optional() + } + + pub async fn delete_by_user(&mut self, user_id: &Uuid) -> Result, Error> { + diesel::delete(app_api_keys::table) + .filter(app_api_keys::user_id.eq(user_id)) + .returning(app_api_keys::id) + .get_results(self.db) + .await + } +} diff --git a/server-new/src/db/repositories/chat.rs b/server-new/src/db/repositories/chat.rs new file mode 100644 index 0000000..55aca3b --- /dev/null +++ b/server-new/src/db/repositories/chat.rs @@ -0,0 +1,198 @@ +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::db::{ + DbConnection, + models::{ + ChatRsLogKind, ChatRsLogLlmRequest, ChatRsMessage, ChatRsSession, NewChatRsMessage, + NewChatRsSession, UpdateChatRsSession, + }, + queries::{FullTextSearchResult, full_text_query}, + schema::{chat_messages, chat_sessions, llm_logs}, +}; + +pub struct ChatRepository<'a> { + pub db: &'a mut DbConnection, +} + +impl<'a> ChatRepository<'a> { + pub fn new(db: &'a mut DbConnection) -> Self { + ChatRepository { db } + } + + pub async fn create_session( + &mut self, + session: NewChatRsSession<'_>, + ) -> Result { + let id = diesel::insert_into(chat_sessions::table) + .values(session) + .returning(chat_sessions::id) + .get_result(self.db) + .await?; + Ok(id) + } + + pub async fn save_message( + &mut self, + message: NewChatRsMessage<'_>, + ) -> Result { + let message = diesel::insert_into(chat_messages::table) + .values(message) + .returning(ChatRsMessage::as_select()) + .get_result(self.db) + .await?; + Ok(message) + } + + pub async fn save_messages( + &mut self, + messages: &[NewChatRsMessage<'_>], + ) -> Result, diesel::result::Error> { + let messages = diesel::insert_into(chat_messages::table) + .values(messages) + .returning(ChatRsMessage::as_select()) + .get_results(self.db) + .await?; + Ok(messages) + } + + pub async fn find_message( + &mut self, + user_id: &Uuid, + message_id: &Uuid, + ) -> Result, diesel::result::Error> { + chat_messages::table + .inner_join(chat_sessions::table) + .select(ChatRsMessage::as_select()) + .filter(chat_sessions::user_id.eq(user_id)) + .filter(chat_messages::id.eq(message_id)) + .get_result(self.db) + .await + .optional() + } + + pub async fn delete_message( + &mut self, + session_id: &Uuid, + message_id: &Uuid, + ) -> Result, diesel::result::Error> { + diesel::delete(chat_messages::table) + .filter(chat_messages::session_id.eq(session_id)) + .filter(chat_messages::id.eq(message_id)) + .returning(chat_messages::id) + .get_result(self.db) + .await + .optional() + } + + pub async fn list_recent_sessions( + &mut self, + user_id: &Uuid, + ) -> Result, diesel::result::Error> { + let sessions = chat_sessions::table + .filter(chat_sessions::user_id.eq(user_id)) + .select(ChatRsSession::as_select()) + .order_by(chat_sessions::updated_at.desc()) + .limit(100) + .load(self.db) + .await?; + + Ok(sessions) + } + + pub async fn find_session( + &mut self, + user_id: &Uuid, + session_id: &Uuid, + ) -> Result, diesel::result::Error> { + let session = chat_sessions::table + .filter(chat_sessions::user_id.eq(user_id)) + .filter(chat_sessions::id.eq(session_id)) + .select(ChatRsSession::as_select()) + .first(self.db) + .await + .optional()?; + + Ok(session) + } + + pub async fn list_messages(&mut self, session_id: &Uuid) -> QueryResult> { + let messages = chat_messages::table + .filter(chat_messages::session_id.eq(session_id)) + .select(ChatRsMessage::as_select()) + .order_by(chat_messages::created_at.asc()) + .load(self.db) + .await?; + + Ok(messages) + } + + pub async fn list_messages_with_logs( + &mut self, + session_id: &Uuid, + ) -> QueryResult)>> { + let messages = chat_messages::table + .left_join( + llm_logs::table.on(llm_logs::message_id + .eq(chat_messages::id.nullable()) + .and(llm_logs::kind.eq(ChatRsLogKind::Chat.as_ref()))), + ) + .filter(chat_messages::session_id.eq(session_id)) + .select(( + ChatRsMessage::as_select(), + Option::::as_select(), + )) + .order_by(chat_messages::created_at.asc()) + .load(self.db) + .await?; + + Ok(messages) + } + + pub async fn search_sessions( + &mut self, + user_id: &Uuid, + query: &str, + ) -> Result, diesel::result::Error> { + full_text_query(self.db, user_id, query, 10).await + } + + pub async fn update_session( + &mut self, + user_id: &Uuid, + session_id: &Uuid, + data: UpdateChatRsSession<'_>, + ) -> Result, diesel::result::Error> { + diesel::update(chat_sessions::table.find(session_id)) + .set(data) + .filter(chat_sessions::user_id.eq(user_id)) + .returning(chat_sessions::id) + .get_result(self.db) + .await + .optional() + } + + pub async fn delete_session( + &mut self, + user_id: &Uuid, + session_id: &Uuid, + ) -> Result, diesel::result::Error> { + diesel::delete(chat_sessions::table.find(session_id)) + .filter(chat_sessions::user_id.eq(user_id)) + .returning(chat_sessions::id) + .get_result(self.db) + .await + .optional() + } + + pub async fn delete_sessions_by_user( + &mut self, + user_id: &Uuid, + ) -> Result { + diesel::delete(chat_sessions::table) + .filter(chat_sessions::user_id.eq(user_id)) + .execute(self.db) + .await + } +} diff --git a/server-new/src/db/repositories/file.rs b/server-new/src/db/repositories/file.rs new file mode 100644 index 0000000..a72fc0d --- /dev/null +++ b/server-new/src/db/repositories/file.rs @@ -0,0 +1,137 @@ +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::db::{ + DbConnection, + models::{ChatRsFile, ChatRsMessageAttachment, NewChatRsFile, NewChatRsMessageAttachment}, + schema::{chat_messages, chat_sessions, files, message_attachments}, +}; + +pub struct FileRepository<'a> { + pub db: &'a mut DbConnection, +} + +impl<'a> FileRepository<'a> { + pub fn new(db: &'a mut DbConnection) -> Self { + Self { db } + } + + pub async fn create_file(&mut self, file: NewChatRsFile<'_>) -> QueryResult { + diesel::insert_into(files::table) + .values(file) + .returning(ChatRsFile::as_returning()) + .get_result(self.db) + .await + } + + pub async fn find_user_file( + &mut self, + user_id: &Uuid, + file_id: &Uuid, + ) -> QueryResult> { + files::table + .filter(files::user_id.eq(user_id)) + .filter(files::session_id.is_null()) + .filter(files::id.eq(file_id)) + .select(ChatRsFile::as_select()) + .first(self.db) + .await + .optional() + } + + pub async fn find_session_file( + &mut self, + user_id: &Uuid, + session_id: &Uuid, + file_id: &Uuid, + ) -> QueryResult> { + files::table + .filter(files::user_id.eq(user_id)) + .filter(files::session_id.eq(session_id)) + .filter(files::id.eq(file_id)) + .select(ChatRsFile::as_select()) + .first(self.db) + .await + .optional() + } + + pub async fn attach_files( + &mut self, + message_id: &Uuid, + file_ids: &[Uuid], + ) -> QueryResult { + let attachments = file_ids.iter().map(|file_id| NewChatRsMessageAttachment { + message_id, + file_id, + }); + + diesel::insert_into(message_attachments::table) + .values(attachments.collect::>()) + .returning(ChatRsMessageAttachment::as_returning()) + .get_result(self.db) + .await + } + + pub async fn list_user_files(&mut self, user_id: &Uuid) -> QueryResult> { + files::table + .filter(files::user_id.eq(user_id)) + .filter(files::session_id.is_null()) + .select(ChatRsFile::as_select()) + .load(self.db) + .await + } + + pub async fn list_session_files_and_attachments( + &mut self, + user_id: &Uuid, + session_id: &Uuid, + ) -> QueryResult<(Vec, Vec)> { + let (files, attachments) = futures::future::try_join( + files::table + .filter(files::user_id.eq(user_id)) + .filter(files::session_id.eq(session_id)) + .select(ChatRsFile::as_select()) + .load(&mut self.db.as_ref()), + files::table + .inner_join(message_attachments::table) + .inner_join( + chat_messages::table.on(message_attachments::message_id.eq(chat_messages::id)), + ) + .inner_join( + chat_sessions::table.on(chat_sessions::id.eq(chat_messages::session_id)), + ) + .filter(chat_sessions::user_id.eq(user_id)) + .filter(chat_sessions::id.eq(session_id)) + .select(ChatRsMessageAttachment::as_select()) + .load(&mut self.db.as_ref()), + ) + .await?; + + Ok((files, attachments)) + } + + pub async fn delete_user_file(&mut self, user_id: &Uuid, file_id: &Uuid) -> QueryResult { + diesel::delete(files::table) + .filter(files::user_id.eq(user_id)) + .filter(files::id.eq(file_id)) + .returning(files::id) + .get_result(self.db) + .await + } + + pub async fn delete_session_file( + &mut self, + user_id: &Uuid, + session_id: &Uuid, + file_id: &Uuid, + ) -> QueryResult { + diesel::delete(files::table) + .filter(files::user_id.eq(user_id)) + .filter(files::session_id.eq(session_id)) + .filter(files::id.eq(file_id)) + .returning(files::id) + .get_result(self.db) + .await + } +} diff --git a/server-new/src/db/repositories/log.rs b/server-new/src/db/repositories/log.rs new file mode 100644 index 0000000..9afdd91 --- /dev/null +++ b/server-new/src/db/repositories/log.rs @@ -0,0 +1,122 @@ +use std::time::Duration; + +use bigdecimal::{BigDecimal, FromPrimitive}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::{ + db::{ + DbConnection, UtcDateTime, + models::{ + ChatRsLogKind, ChatRsLogMeta, ChatRsLogMetaOptions, ChatRsLogStatus, NewChatRsLog, + UpdateChatRsLog, + }, + schema::llm_logs, + }, + llm::types::{LlmChatOptions, LlmUsage}, +}; + +pub struct LogRepository<'a> { + db: &'a mut DbConnection, +} +impl<'a> LogRepository<'a> { + pub fn new(db: &'a mut DbConnection) -> Self { + LogRepository { db } + } + + /// Create a new LLM request log entry + pub async fn create( + &mut self, + LlmLogCreate { + user_id, + provider_id, + llm_options, + kind, + session_id, + }: LlmLogCreate<'_>, + ) -> QueryResult { + let new_log = NewChatRsLog { + kind: kind.as_ref(), + user_id: &user_id, + provider_id, + session_id, + model: llm_options + .as_ref() + .map(|o| o.model.as_str()) + .unwrap_or_default(), + status: ChatRsLogStatus::Started.as_ref(), + meta: Some(&ChatRsLogMeta { + options: Some(ChatRsLogMetaOptions { + temperature: llm_options.and_then(|o| o.temperature), + max_tokens: llm_options.and_then(|o| o.max_tokens), + }), + ..Default::default() + }), + started_at: chrono::Utc::now(), + }; + + diesel::insert_into(llm_logs::table) + .values(new_log) + .returning(UpdateChatRsLog::as_returning()) + .get_result(self.db) + .await + } + + /// Complete a LLM request log entry + pub async fn complete( + &mut self, + log: UpdateChatRsLog, + LlmLogComplete { + message_id, + request_id, + usage, + errors, + first_token_in, + status, + completed_at, + }: LlmLogComplete<'_>, + ) -> QueryResult { + let updated_log = UpdateChatRsLog { + id: log.id, + message_id, + input_tokens: usage.and_then(|u| u.input_tokens), + output_tokens: usage.and_then(|u| u.output_tokens), + cost: usage.and_then(|u| u.cost.and_then(BigDecimal::from_f32)), + status: status.as_ref().to_owned(), + completed_at: Some(completed_at.unwrap_or_else(chrono::Utc::now)), + ttft_ms: first_token_in.and_then(|d| d.as_millis().try_into().ok()), + meta: ChatRsLogMeta { + errors, + request_id: request_id.map(str::to_owned), + ..log.meta + }, + }; + + diesel::update(&updated_log) + .set(&updated_log) + .returning(llm_logs::id) + .get_result(self.db) + .await + } +} + +#[derive(Debug, Default)] +pub struct LlmLogCreate<'a> { + pub kind: ChatRsLogKind, + pub user_id: Uuid, + pub provider_id: i32, + pub session_id: Option<&'a Uuid>, + pub llm_options: Option<&'a LlmChatOptions>, +} + +#[derive(Debug, Default)] +pub struct LlmLogComplete<'a> { + pub status: ChatRsLogStatus, + pub message_id: Option, + pub request_id: Option<&'a str>, + pub usage: Option<&'a LlmUsage>, + pub errors: Option>, + pub first_token_in: Option, + pub completed_at: Option, +} diff --git a/server-new/src/db/repositories/provider.rs b/server-new/src/db/repositories/provider.rs new file mode 100644 index 0000000..0459a63 --- /dev/null +++ b/server-new/src/db/repositories/provider.rs @@ -0,0 +1,98 @@ +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::db::{ + DbConnection, + models::{ChatRsProvider, ChatRsSecret, NewChatRsProvider, UpdateChatRsProvider}, + schema::{providers, secrets}, +}; + +pub struct ProviderRepository<'a> { + pub db: &'a mut DbConnection, +} + +impl<'a> ProviderRepository<'a> { + pub fn new(db: &'a mut DbConnection) -> Self { + ProviderRepository { db } + } + + pub async fn find_by_id( + &mut self, + user_id: &Uuid, + provider_id: i32, + ) -> Result)>, diesel::result::Error> { + providers::table + .left_join(secrets::table) + .filter(providers::user_id.eq(user_id)) + .filter(providers::id.eq(provider_id)) + .select(( + ChatRsProvider::as_select(), + Option::::as_select(), + )) + .first(self.db) + .await + .optional() + } + + pub async fn list_by_user_id( + &mut self, + user_id: &Uuid, + ) -> Result, diesel::result::Error> { + providers::table + .filter(providers::user_id.eq(user_id)) + .select(ChatRsProvider::as_select()) + .load(self.db) + .await + } + + pub async fn create( + &mut self, + provider: NewChatRsProvider<'_>, + ) -> Result { + diesel::insert_into(providers::table) + .values(provider) + .returning(ChatRsProvider::as_returning()) + .get_result(self.db) + .await + } + + pub async fn update( + &mut self, + user_id: &Uuid, + provider_id: i32, + data: UpdateChatRsProvider<'_>, + ) -> Result { + diesel::update(providers::table) + .filter(providers::user_id.eq(user_id)) + .filter(providers::id.eq(provider_id)) + .set(data) + .returning(ChatRsProvider::as_returning()) + .get_result(self.db) + .await + } + + pub async fn delete( + &mut self, + user_id: &Uuid, + provider_id: i32, + ) -> Result { + diesel::delete(providers::table) + .filter(providers::user_id.eq(user_id)) + .filter(providers::id.eq(provider_id)) + .returning(ChatRsProvider::as_returning()) + .get_result(self.db) + .await + } + + pub async fn delete_by_user( + &mut self, + user_id: &Uuid, + ) -> Result, diesel::result::Error> { + diesel::delete(providers::table) + .filter(providers::user_id.eq(user_id)) + .returning(ChatRsProvider::as_returning()) + .get_results(self.db) + .await + } +} diff --git a/server-new/src/db/repositories/secret.rs b/server-new/src/db/repositories/secret.rs new file mode 100644 index 0000000..a4c0de2 --- /dev/null +++ b/server-new/src/db/repositories/secret.rs @@ -0,0 +1,81 @@ +use diesel::prelude::*; +use diesel::result::Error; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::db::{ + DbConnection, + models::{ChatRsSecretMeta, NewChatRsSecret, UpdateChatRsSecret}, + schema::secrets, +}; + +pub struct SecretRepository<'a> { + pub db: &'a mut DbConnection, +} + +impl<'a> SecretRepository<'a> { + pub fn new(db: &'a mut DbConnection) -> Self { + SecretRepository { db } + } + + pub async fn find_by_user_id( + &mut self, + user_id: &Uuid, + ) -> Result, Error> { + let keys = secrets::table + .filter(secrets::user_id.eq(user_id)) + .select(ChatRsSecretMeta::as_select()) + .load(self.db) + .await?; + + Ok(keys) + } + + pub async fn create(&mut self, secret: NewChatRsSecret<'_>) -> Result { + let id: Uuid = diesel::insert_into(secrets::table) + .values(secret) + .returning(secrets::id) + .get_result(self.db) + .await?; + + Ok(id) + } + + pub async fn update( + &mut self, + user_id: &Uuid, + secret_id: &Uuid, + data: UpdateChatRsSecret<'_>, + ) -> Result { + let id: Uuid = diesel::update(secrets::table) + .filter(secrets::id.eq(secret_id)) + .filter(secrets::user_id.eq(user_id)) + .set(data) + .returning(secrets::id) + .get_result(self.db) + .await?; + + Ok(id) + } + + pub async fn delete(&mut self, user_id: &Uuid, secret_id: &Uuid) -> Result { + let id: Uuid = diesel::delete(secrets::table) + .filter(secrets::id.eq(secret_id)) + .filter(secrets::user_id.eq(user_id)) + .returning(secrets::id) + .get_result(self.db) + .await?; + + Ok(id) + } + + pub async fn delete_by_user(&mut self, user_id: &Uuid) -> Result, Error> { + let ids: Vec = diesel::delete(secrets::table) + .filter(secrets::user_id.eq(user_id)) + .returning(secrets::id) + .get_results(self.db) + .await?; + + Ok(ids) + } +} diff --git a/server-new/src/db/repositories/session.rs b/server-new/src/db/repositories/session.rs new file mode 100644 index 0000000..dbe0952 --- /dev/null +++ b/server-new/src/db/repositories/session.rs @@ -0,0 +1,97 @@ +use std::collections::HashMap; + +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::db::{ + DbConnection, UtcDateTime, + models::{AuthSessionData, ChatRsAuthSession, NewChatRsAuthSession, UpdateChatRsAuthSession}, + schema::auth_sessions, +}; + +pub struct SessionRepository<'a> { + db: &'a mut DbConnection, +} + +impl<'a> SessionRepository<'a> { + pub fn new(db: &'a mut DbConnection) -> Self { + Self { db } + } + + /// Find an active (not expired) session by ID + pub async fn find_active_by_id( + &mut self, + session_id: &Uuid, + ) -> QueryResult> { + auth_sessions::table + .find(session_id) + .filter(auth_sessions::expires_at.gt(diesel::dsl::now)) + .select(ChatRsAuthSession::as_select()) + .first(self.db) + .await + .optional() + } + + pub async fn list_active_by_user_id( + &mut self, + user_id: &Uuid, + ) -> QueryResult> { + auth_sessions::table + .filter(auth_sessions::user_id.eq(user_id)) + .filter(auth_sessions::expires_at.gt(diesel::dsl::now)) + .select(ChatRsAuthSession::as_select()) + .load(self.db) + .await + } + + pub async fn create( + &mut self, + session_id: &Uuid, + user_id: Option<&Uuid>, + data: &HashMap, + expires_at: UtcDateTime, + ) -> QueryResult { + diesel::insert_into(auth_sessions::table) + .values(NewChatRsAuthSession { + id: session_id, + user_id, + data: AuthSessionData(data.to_owned()), + expires_at, + }) + .returning(ChatRsAuthSession::as_returning()) + .get_result(self.db) + .await + } + + pub async fn update( + &mut self, + session_id: &Uuid, + data: &HashMap, + expires_at: UtcDateTime, + ) -> QueryResult { + diesel::update(auth_sessions::table.find(session_id)) + .set(UpdateChatRsAuthSession { + data: AuthSessionData(data.to_owned()), + expires_at, + }) + .returning(ChatRsAuthSession::as_returning()) + .get_result(self.db) + .await + } + + /// Delete a session by ID. Won't return an error if it does not exist. + pub async fn delete_by_id(&mut self, session_id: &Uuid) -> QueryResult { + diesel::delete(auth_sessions::table.find(session_id)) + .execute(self.db) + .await + } + + /// Delete all expired sessions + pub async fn delete_expired(&mut self) -> QueryResult { + diesel::delete(auth_sessions::table) + .filter(auth_sessions::expires_at.le(diesel::dsl::now)) + .execute(self.db) + .await + } +} diff --git a/server-new/src/db/repositories/user.rs b/server-new/src/db/repositories/user.rs new file mode 100644 index 0000000..25f3908 --- /dev/null +++ b/server-new/src/db/repositories/user.rs @@ -0,0 +1,117 @@ +use diesel::prelude::*; +use diesel::result::Error; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::db::{ + DbConnection, + models::{ChatRsUser, NewChatRsUser, UpdateChatRsUser}, + schema::users, +}; + +pub struct UserRepository<'a> { + db: &'a mut DbConnection, +} + +impl<'a> UserRepository<'a> { + pub fn new(db: &'a mut DbConnection) -> Self { + UserRepository { db } + } + + pub async fn create(&mut self, user: NewChatRsUser<'_>) -> Result { + diesel::insert_into(users::table) + .values(user) + .returning(ChatRsUser::as_returning()) + .get_result(self.db) + .await + } + + pub async fn find_by_id(&mut self, id: &Uuid) -> Result, Error> { + let user = users::table + .filter(users::id.eq(id)) + .select(ChatRsUser::as_select()) + .first(self.db) + .await + .optional()?; + + Ok(user) + } + + pub async fn find_by_github_id(&mut self, id: &str) -> Result, Error> { + let user = users::table + .filter(users::github_id.eq(id)) + .select(ChatRsUser::as_select()) + .first(self.db) + .await + .optional()?; + + Ok(user) + } + + pub async fn find_by_google_id(&mut self, id: &str) -> Result, Error> { + let user = users::table + .filter(users::google_id.eq(id)) + .select(ChatRsUser::as_select()) + .first(self.db) + .await + .optional()?; + + Ok(user) + } + + pub async fn find_by_discord_id(&mut self, id: &str) -> Result, Error> { + let user = users::table + .filter(users::discord_id.eq(id)) + .select(ChatRsUser::as_select()) + .first(self.db) + .await + .optional()?; + + Ok(user) + } + + pub async fn find_by_oidc_id(&mut self, id: &str) -> Result, Error> { + let user = users::table + .filter(users::oidc_id.eq(id)) + .select(ChatRsUser::as_select()) + .first(self.db) + .await + .optional()?; + + Ok(user) + } + + pub async fn find_by_sso_username(&mut self, username: &str) -> Result, Error> { + let user_id = users::table + .filter(users::sso_username.eq(username)) + .select(users::id) + .first(self.db) + .await + .optional()?; + + Ok(user_id) + } + + pub async fn update( + &mut self, + user_id: &Uuid, + data: UpdateChatRsUser<'_>, + ) -> Result { + let updated_id: Uuid = diesel::update(users::table.find(user_id)) + .set(data) + .returning(users::id) + .get_result(self.db) + .await?; + + Ok(updated_id) + } + + // pub async fn delete(&mut self, user_id: &Uuid) -> Result { + // let id: Uuid = diesel::delete(users::table.find(user_id)) + // .returning(users::id) + // .get_result(self.db) + // .await?; + + // Ok(id) + // } +} diff --git a/server-new/src/db/schema.rs b/server-new/src/db/schema.rs new file mode 100644 index 0000000..c3cf047 --- /dev/null +++ b/server-new/src/db/schema.rs @@ -0,0 +1,195 @@ +// @generated automatically by Diesel CLI. + +pub mod sql_types { + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "chat_message_role"))] + pub struct ChatMessageRole; + + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "tsvector", schema = "pg_catalog"))] + pub struct Tsvector; +} + +diesel::table! { + app_api_keys (id) { + id -> Uuid, + user_id -> Uuid, + name -> Text, + created_at -> Timestamptz, + } +} + +diesel::table! { + auth_sessions (id) { + id -> Uuid, + user_id -> Nullable, + data -> Jsonb, + expires_at -> Timestamptz, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::ChatMessageRole; + use super::sql_types::Tsvector; + + chat_messages (id) { + id -> Uuid, + session_id -> Uuid, + role -> ChatMessageRole, + content -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + meta -> Jsonb, + search_vector -> Tsvector, + } +} + +diesel::table! { + chat_sessions (id) { + id -> Uuid, + title -> Varchar, + created_at -> Timestamptz, + updated_at -> Timestamptz, + user_id -> Uuid, + meta -> Jsonb, + } +} + +diesel::table! { + external_api_tools (id) { + id -> Uuid, + user_id -> Uuid, + data -> Jsonb, + secret_1 -> Nullable, + secret_2 -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + files (id) { + id -> Uuid, + user_id -> Uuid, + session_id -> Nullable, + path -> Text, + file_type -> Text, + content_type -> Text, + size -> Int4, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + llm_logs (id) { + id -> Int4, + kind -> Text, + user_id -> Uuid, + provider_id -> Nullable, + session_id -> Nullable, + message_id -> Nullable, + model -> Text, + input_tokens -> Nullable, + output_tokens -> Nullable, + cost -> Nullable, + ttft_ms -> Nullable, + status -> Text, + meta -> Jsonb, + started_at -> Timestamptz, + completed_at -> Nullable, + } +} + +diesel::table! { + message_attachments (message_id, file_id) { + message_id -> Uuid, + file_id -> Uuid, + } +} + +diesel::table! { + providers (id) { + id -> Int4, + name -> Text, + provider_type -> Text, + user_id -> Uuid, + base_url -> Nullable, + default_model -> Text, + api_key_id -> Nullable, + created_at -> Timestamptz, + openai_subtype -> Nullable, + } +} + +diesel::table! { + secrets (id) { + id -> Uuid, + user_id -> Uuid, + ciphertext -> Bytea, + nonce -> Bytea, + created_at -> Timestamptz, + name -> Text, + } +} + +diesel::table! { + system_tools (id) { + id -> Uuid, + user_id -> Uuid, + data -> Jsonb, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + users (id) { + id -> Uuid, + github_id -> Nullable, + name -> Varchar, + created_at -> Timestamptz, + updated_at -> Timestamptz, + sso_username -> Nullable, + google_id -> Nullable, + discord_id -> Nullable, + oidc_id -> Nullable, + avatar_url -> Nullable, + } +} + +diesel::joinable!(app_api_keys -> users (user_id)); +diesel::joinable!(auth_sessions -> users (user_id)); +diesel::joinable!(chat_messages -> chat_sessions (session_id)); +diesel::joinable!(chat_sessions -> users (user_id)); +diesel::joinable!(external_api_tools -> users (user_id)); +diesel::joinable!(files -> chat_sessions (session_id)); +diesel::joinable!(files -> users (user_id)); +diesel::joinable!(llm_logs -> chat_messages (message_id)); +diesel::joinable!(llm_logs -> chat_sessions (session_id)); +diesel::joinable!(llm_logs -> providers (provider_id)); +diesel::joinable!(llm_logs -> users (user_id)); +diesel::joinable!(message_attachments -> chat_messages (message_id)); +diesel::joinable!(message_attachments -> files (file_id)); +diesel::joinable!(providers -> secrets (api_key_id)); +diesel::joinable!(providers -> users (user_id)); +diesel::joinable!(secrets -> users (user_id)); +diesel::joinable!(system_tools -> users (user_id)); + +diesel::allow_tables_to_appear_in_same_query!( + app_api_keys, + auth_sessions, + chat_messages, + chat_sessions, + external_api_tools, + files, + llm_logs, + message_attachments, + providers, + secrets, + system_tools, + users, +); diff --git a/server-new/src/error.rs b/server-new/src/error.rs new file mode 100644 index 0000000..57eda5f --- /dev/null +++ b/server-new/src/error.rs @@ -0,0 +1,118 @@ +use aide::OperationOutput; +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::db::DbPoolError; + +/// Global API result type that can be used in route handlers +pub type AppResult = Result; + +/// Global API error type +#[derive(Debug)] +pub struct AppError { + status: StatusCode, + message: String, + source: Option, +} + +impl AppError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { + status, + message: message.into(), + source: None, + } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + + pub fn unauthorized(source: impl Into) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: "unauthorized".into(), + source: Some(anyhow::anyhow!(source.into())), + } + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, message) + } + + pub fn internal(error: anyhow::Error) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: "internal server error".to_string(), + source: Some(error), + } + } +} + +impl From for AppError { + fn from(err: diesel::result::Error) -> Self { + Self::internal(anyhow::Error::from(err).context("database error")) + } +} +impl From for AppError { + fn from(err: DbPoolError) -> Self { + Self::internal(anyhow::Error::from(err).context("database pool error")) + } +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ErrorResponse { + error: ErrorBody, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ErrorBody { + message: String, + status: u16, +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + if let Some(error) = self.source { + tracing::warn!(error = ?error, "request failed"); + } + + let response = ErrorResponse { + error: ErrorBody { + message: self.message, + status: self.status.as_u16(), + }, + }; + + (self.status, Json(response)).into_response() + } +} + +impl OperationOutput for AppError { + type Inner = ErrorResponse; + + fn inferred_responses( + ctx: &mut aide::generate::GenContext, + operation: &mut aide::openapi::Operation, + ) -> Vec<(Option, aide::openapi::Response)> { + if let Some(response) = Json::::operation_response(ctx, operation) { + let status_codes = [ + StatusCode::BAD_REQUEST, + StatusCode::UNAUTHORIZED, + StatusCode::NOT_FOUND, + StatusCode::INTERNAL_SERVER_ERROR, + ]; + Vec::from_iter(status_codes.into_iter().map(|code| { + let aide_code = aide::openapi::StatusCode::Code(code.as_u16()); + (Some(aide_code), response.clone()) + })) + } else { + Vec::new() + } + } +} diff --git a/server-new/src/extractors/auth_config.rs b/server-new/src/extractors/auth_config.rs new file mode 100644 index 0000000..2d93571 --- /dev/null +++ b/server-new/src/extractors/auth_config.rs @@ -0,0 +1,55 @@ +use aide::OperationIo; +use axum::extract::FromRequestParts; +use schemars::JsonSchema; +use serde::Serialize; +use serde_with::skip_serializing_none; + +use crate::state::AppState; + +/// The current auth configuration of the server +#[skip_serializing_none] +#[derive(Debug, Serialize, JsonSchema, OperationIo)] +pub struct PublicAuthConfig { + /// Whether GitHub login is enabled + github: bool, + /// Whether Google login is enabled + google: bool, + /// Whether Discord login is enabled + discord: bool, + /// OIDC configuration + oidc: Option, + // /// SSO configuration + // sso: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct Oidc { + /// The name of the OIDC provider + name: String, +} + +// #[derive(Debug, JsonSchema, serde::Serialize)] +// struct SSO { +// /// Whether SSO header authentication is enabled +// enabled: bool, +// /// The URL to redirect to after logout +// logout_url: Option, +// } + +impl FromRequestParts for PublicAuthConfig { + type Rejection = (); + + async fn from_request_parts( + _parts: &mut axum::http::request::Parts, + state: &AppState, + ) -> Result { + Ok(PublicAuthConfig { + github: state.config.auth.github.is_some(), + discord: state.config.auth.discord.is_some(), + google: state.config.auth.google.is_some(), + oidc: state.config.auth.oidc.as_ref().map(|oidc| Oidc { + name: oidc.name.as_deref().unwrap_or("OIDC").to_owned(), + }), + }) + } +} diff --git a/server-new/src/extractors/database.rs b/server-new/src/extractors/database.rs new file mode 100644 index 0000000..6faa586 --- /dev/null +++ b/server-new/src/extractors/database.rs @@ -0,0 +1,22 @@ +use aide::OperationIo; +use axum::extract::FromRequestParts; + +use crate::{db::DbService, error::AppError, state::AppState}; + +/// An extractor to retrieve a database connection from the pool +#[derive(OperationIo)] +pub struct Database(pub DbService); + +impl FromRequestParts for Database { + type Rejection = AppError; + + async fn from_request_parts( + _parts: &mut axum::http::request::Parts, + state: &AppState, + ) -> Result { + match DbService::from_pool(&state.db_pool).await { + Ok(db_service) => Ok(Self(db_service)), + Err(err) => Err(AppError::internal(err.into())), + } + } +} diff --git a/server-new/src/extractors/mod.rs b/server-new/src/extractors/mod.rs new file mode 100644 index 0000000..7126bd3 --- /dev/null +++ b/server-new/src/extractors/mod.rs @@ -0,0 +1,13 @@ +//! Extractors to be used in API route handlers + +mod auth_config; +mod database; +mod session; +mod upload; +mod user; + +pub use auth_config::PublicAuthConfig; +pub use database::Database; +pub use session::{AppSession, SessionMeta}; +pub use upload::FileUpload; +pub use user::CurrentUser; diff --git a/server-new/src/extractors/session.rs b/server-new/src/extractors/session.rs new file mode 100644 index 0000000..1e5c372 --- /dev/null +++ b/server-new/src/extractors/session.rs @@ -0,0 +1,83 @@ +use std::{ + net::{IpAddr, SocketAddr}, + str::FromStr, +}; + +use aide::OperationIo; +use anyhow::anyhow; +use axum::{ + extract::{ConnectInfo, FromRequestParts}, + http::header, +}; +use serde::{Deserialize, Serialize}; +use tower_sessions::Session; + +use crate::{db::UtcDateTime, error::AppError, state::AppState}; + +/// Extractor to get raw session and request metadata +#[derive(OperationIo)] +pub struct AppSession { + pub session: Session, + pub meta: SessionMeta, +} + +/// Session metadata extracted on login. +#[derive(Debug, Clone, Serialize, Deserialize, OperationIo)] +pub struct SessionMeta { + pub start_time: UtcDateTime, + pub ip: Option, + pub user_agent: Option, +} + +impl FromRequestParts for AppSession { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &AppState, + ) -> Result { + let meta = SessionMeta::from_request_parts(parts, state).await?; + let session = parts + .extensions + .get::() + .cloned() + .ok_or_else(|| AppError::internal(anyhow!("session not attached to request")))?; + + Ok(Self { session, meta }) + } +} + +impl FromRequestParts for SessionMeta { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &AppState, + ) -> Result { + let ip_header = state + .config + .server + .ip_header + .as_ref() + .and_then(|h| parts.headers.get(h).and_then(|h| h.to_str().ok())) + .and_then(|h| IpAddr::from_str(h).ok()); + let ip = match ip_header { + Some(ip) => Some(ip), + None => ConnectInfo::::from_request_parts(parts, state) + .await + .ok() + .map(|info| info.ip()), + }; + let user_agent = parts + .headers + .get(header::USER_AGENT) + .and_then(|h| h.to_str().ok()) + .map(|ua| ua.to_owned()); + + Ok(Self { + ip, + user_agent, + start_time: chrono::Utc::now(), + }) + } +} diff --git a/server-new/src/extractors/upload.rs b/server-new/src/extractors/upload.rs new file mode 100644 index 0000000..8e89396 --- /dev/null +++ b/server-new/src/extractors/upload.rs @@ -0,0 +1,57 @@ +use aide::OperationIo; +use axum::{RequestExt, extract::FromRequest, http::header}; +use futures::{Stream, TryStreamExt}; + +use crate::{error::AppError, state::AppState}; + +/// Extractor to get a streaming uploaded file +#[derive(OperationIo)] +pub struct FileUpload { + body: axum::body::Body, + content_type: String, + content_length: usize, +} + +impl FromRequest for FileUpload { + type Rejection = AppError; + + async fn from_request( + req: axum::extract::Request, + _state: &AppState, + ) -> Result { + let content_type = req + .headers() + .get(header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| AppError::bad_request("no content-type header"))? + .to_owned(); + let content_length: usize = req + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.parse().ok()) + .ok_or_else(|| AppError::bad_request("no content-length header"))?; + + let body = req.into_limited_body(); + + Ok(Self { + body, + content_length, + content_type, + }) + } +} + +impl FileUpload { + pub fn content_type(&self) -> String { + self.content_type.to_owned() + } + + pub fn size(&self) -> usize { + self.content_length + } + + pub fn into_stream(self) -> impl Stream> { + self.body.into_data_stream().map_err(std::io::Error::other) + } +} diff --git a/server-new/src/extractors/user.rs b/server-new/src/extractors/user.rs new file mode 100644 index 0000000..a97fbd1 --- /dev/null +++ b/server-new/src/extractors/user.rs @@ -0,0 +1,104 @@ +use aide::OperationInput; +use anyhow::anyhow; +use axum::{ + extract::{FromRequestParts, OptionalFromRequestParts}, + http::header, +}; +use uuid::Uuid; + +use crate::{db::DbService, error::AppError, state::AppState}; + +/** +Represents an active user, extracted from the session, proxy headers, or API key. This can be used +as an extractor in route handlers: +- If used as `CurrentUser`, request will automatically return an unauthorized error + if there is no active user. +- If used as `Option`, will be `Some` if there is an active user + and `None` otherwise. +*/ +#[derive(Debug)] +pub struct CurrentUser { + pub user_id: Uuid, +} + +impl CurrentUser { + fn new(user_id: Uuid) -> Self { + Self { user_id } + } +} + +impl OptionalFromRequestParts for CurrentUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &AppState, + ) -> Result, Self::Rejection> { + let auth_service = state.auth_service(); + + // If there is an Authorization header, validate the API key + if let Some(auth_header) = parts + .headers + .get(header::AUTHORIZATION) + .and_then(|bytes| bytes.to_str().ok()) + { + let user_id = auth_service + .api_keys() + .validate_api_key(&state.db_pool, auth_header) + .await?; + + Ok(Some(Self { user_id })) + } else { + // If SSO header / proxy auth is enabled, check forwarded headers first + if state.config.auth.proxy.enabled { + let proxy_service = auth_service.proxy(); + if let Some(proxy_user) = proxy_service.extract_proxy_user(&parts.headers)? { + let mut db = DbService::from_pool(&state.db_pool).await?; + match proxy_service.find_proxy_user(&mut db, &proxy_user).await? { + Some(user_id) => return Ok(Some(Self::new(user_id))), + None => { + let new_user = proxy_service + .create_proxy_user(&mut db, &proxy_user) + .await?; + return Ok(Some(Self::new(new_user.id))); + } + } + } + } + + // Check for session + let session = parts + .extensions + .get::() + .ok_or_else(|| AppError::internal(anyhow!("session not attached to request")))?; + let maybe_user_id = auth_service.session().active_user_id(session).await?; + + Ok(maybe_user_id.map(Self::new)) + } + } +} + +impl FromRequestParts for CurrentUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &AppState, + ) -> Result { + match >::from_request_parts(parts, state).await? + { + Some(current_user) => Ok(current_user), + None => Err(AppError::unauthorized("no active user")), + } + } +} + +impl OperationInput for CurrentUser { + fn operation_input( + _ctx: &mut aide::generate::GenContext, + operation: &mut aide::openapi::Operation, + ) { + let security_reqs = [(String::from(crate::api::API_KEY_SCHEME), vec![])]; + operation.security.push(security_reqs.into()); + } +} diff --git a/server-new/src/lib.rs b/server-new/src/lib.rs new file mode 100644 index 0000000..66f6e63 --- /dev/null +++ b/server-new/src/lib.rs @@ -0,0 +1,40 @@ +use axum_plugin::{App, InitializedApp}; + +use crate::{config::AppConfig, plugins::AxumPlugin, state::AppState}; + +mod api; +mod config; +mod db; +mod error; +mod extractors; +mod llm; +mod plugins; +mod services; +mod state; + +pub async fn create_app() -> anyhow::Result> { + let app = App::from_figment(config::figment())? + .register(AxumPlugin::named("Config").on_init(async |app| { + let config = app.config(); + tracing::info!( + log_level = config.server.log_level, + base_url = config.server.base_url, + host = %config.server.host, + port = config.server.port, + "Config loaded!" + ); + Ok(app) + })) + .register(plugins::clients::plugin()) // Initialize HTTP clients + .register(plugins::database::plugin()) // Initialize database + .register(plugins::redis::plugin()) // Initialize Redis + .register(api::plugin()) // Add API routes + .register(plugins::auth::plugin()) // Setup auth & sessions + .register(plugins::logging::plugin()) // Request logging + .register(plugins::web::plugin()) // Web app + .register(plugins::security::plugin()) // Body limit, security headers, etc. + .init() + .await?; + + Ok(app) +} diff --git a/server-new/src/llm/error.rs b/server-new/src/llm/error.rs new file mode 100644 index 0000000..eb98e88 --- /dev/null +++ b/server-new/src/llm/error.rs @@ -0,0 +1,36 @@ +use crate::services::stream::error::StreamingError; + +/// Errors that can occur in an LLM provider request +#[derive(Debug, thiserror::Error)] +pub enum LlmRequestError { + /// Provider error message with optional request ID + #[error("{0}")] + Provider(String, Option), + #[error("Failed to read response: {0}")] + Read(#[from] reqwest::Error), + #[error("No content")] + NoContent, +} +impl LlmRequestError { + pub fn req_id(&self) -> Option<&str> { + match self { + LlmRequestError::Provider(_, req_id) => req_id.as_deref(), + _ => None, + } + } +} + +/// Errors that can occur in an LLM stream chunk +#[derive(Debug, thiserror::Error)] +pub enum LlmStreamChunkError { + #[error("Provider error: {0}")] + Provider(String), + #[error("Failed to parse event: {0}")] + Parsing(#[from] serde_json::Error), + #[error("Failed to decode line: {0}")] + Decoding(#[from] tokio_util::codec::LinesCodecError), + #[error(transparent)] + Streaming(#[from] StreamingError), + #[error("Stream was cancelled")] + StreamCancelled, +} diff --git a/server-new/src/llm/interface.rs b/server-new/src/llm/interface.rs new file mode 100644 index 0000000..7b2f344 --- /dev/null +++ b/server-new/src/llm/interface.rs @@ -0,0 +1,49 @@ +use futures::{future::BoxFuture, stream::BoxStream}; + +use super::{ + error::{LlmRequestError, LlmStreamChunkError}, + types::{LlmChatRequest, LlmPrompt, LlmUsage}, +}; + +/// Trait representing an LLM provider +pub trait LlmProvider: Send + Sync { + fn prompt<'r>(&'r self, prompt: LlmPrompt<'r>) -> LlmPromptResponse<'r>; + fn stream_chat<'r>(&'r self, request: LlmChatRequest<'r>) -> LlmStreamingResponse<'r>; +} + +/// API response to a prompt request from the LLM provider +pub type LlmPromptResponse<'r> = BoxFuture<'r, Result>; +/// Initial API response to a streaming request from the LLM provider +pub type LlmStreamingResponse<'r> = + BoxFuture<'r, Result<(LlmStream, LlmResponseMeta), LlmRequestError>>; +/// The response stream from the LLM provider +pub type LlmStream = BoxStream<'static, LlmStreamChunkResult>; +/// The type of the chunks in the LLM response stream +pub type LlmStreamChunkResult = Result; + +/// Prompt response data from the LLM provider +#[derive(Debug, Default)] +pub struct LlmResponse { + pub text: String, + pub usage: LlmUsage, + pub meta: LlmResponseMeta, +} + +#[derive(Debug, Default)] +pub struct LlmResponseMeta { + pub request_id: Option, +} +impl LlmResponseMeta { + pub fn new(request_id: Option) -> Self { + Self { request_id } + } +} + +/// A streaming chunk of data from the LLM provider +pub enum LlmStreamChunk { + Text(String), + Usage(LlmUsage), + // ToolCalls(Vec), + // PendingToolCall(LlmPendingToolCall), + // Images(Vec), +} diff --git a/server-new/src/llm/mod.rs b/server-new/src/llm/mod.rs new file mode 100644 index 0000000..47fe0ca --- /dev/null +++ b/server-new/src/llm/mod.rs @@ -0,0 +1,6 @@ +//! LLM interface and provider implementations + +pub mod error; +pub mod interface; +pub mod providers; +pub mod types; diff --git a/server-new/src/llm/providers/anthropic/mod.rs b/server-new/src/llm/providers/anthropic/mod.rs new file mode 100644 index 0000000..864ab40 --- /dev/null +++ b/server-new/src/llm/providers/anthropic/mod.rs @@ -0,0 +1,129 @@ +//! Anthropic LLM provider + +use futures::StreamExt; + +use crate::llm::{ + error::LlmRequestError, + interface::*, + providers::utils, + types::{LlmChatRequest, LlmPrompt}, +}; + +mod request; +mod response; + +use {request::*, response::*}; + +const MESSAGES_API_URL: &str = "https://api.anthropic.com/v1/messages"; +const API_VERSION: &str = "2023-06-01"; +const REQ_ID_HEADER: &str = "request-id"; +const DEFAULT_MAX_TOKENS: u32 = 4096; + +/// Anthropic chat provider +#[derive(Debug, Clone)] +pub struct AnthropicProvider { + client: reqwest::Client, + api_key: String, +} + +impl AnthropicProvider { + pub fn new(http_client: &reqwest::Client, api_key: impl Into) -> Self { + Self { + client: http_client.clone(), + api_key: api_key.into(), + } + } +} + +impl LlmProvider for AnthropicProvider { + fn prompt<'r>(&'r self, prompt: LlmPrompt<'r>) -> LlmPromptResponse<'r> { + let request = AnthropicRequest { + model: &prompt.options.model, + messages: vec![AnthropicMessage { + role: "user", + content: vec![AnthropicContentBlock::Text { text: prompt.text }], + }], + max_tokens: prompt.options.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS), + temperature: prompt.options.temperature, + system: None, + stream: None, + tools: None, + }; + + Box::pin(async move { + let raw_response = utils::llm_api_request( + self.client + .post(MESSAGES_API_URL) + .header("anthropic-version", API_VERSION) + .header("x-api-key", &self.api_key) + .json(&request), + "Anthropic", + Some(REQ_ID_HEADER), + ) + .await?; + let request_id = utils::extract_header(&raw_response, REQ_ID_HEADER); + let mut response: AnthropicResponse = raw_response.json().await?; + + let text = response + .content + .get_mut(0) + .and_then(|block| match block { + AnthropicResponseContentBlock::Text { text } => Some(std::mem::take(text)), + _ => None, + }) + .ok_or_else(|| LlmRequestError::NoContent)?; + + Ok(LlmResponse { + text, + usage: response.usage.map(Into::into).unwrap_or_default(), + meta: LlmResponseMeta::new(request_id), + }) + }) + } + + fn stream_chat<'r>(&'r self, req: LlmChatRequest<'r>) -> LlmStreamingResponse<'r> { + let (anthropic_messages, system_prompt) = build_anthropic_messages(req.messages); + // let anthropic_tools = tools.as_ref().map(|t| build_anthropic_tools(t)); + let request = AnthropicRequest { + model: &req.options.model, + messages: anthropic_messages, + max_tokens: req.options.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS), + temperature: req.options.temperature, + system: system_prompt, + stream: Some(true), + // tools: anthropic_tools, + ..Default::default() + }; + + Box::pin(async move { + let response = utils::llm_api_request( + self.client + .post(MESSAGES_API_URL) + .header("anthropic-version", API_VERSION) + .header("x-api-key", &self.api_key) + .json(&request), + "Anthropic", + Some(REQ_ID_HEADER), + ) + .await?; + let request_id = utils::extract_header(&response, REQ_ID_HEADER); + + let stream = async_stream::stream! { + let mut sse_event_stream = utils::get_sse_events(response); + // let mut tool_calls = Vec::new(); + while let Some(event_result) = sse_event_stream.next().await { + match event_result { + Ok(event) => { + if let Some(chunk) = parse_anthropic_event(event) { + yield chunk; + } + }, + Err(e) => yield Err(e), + } + } + }; + + Ok((stream.boxed(), LlmResponseMeta::new(request_id))) + }) + } +} diff --git a/server-new/src/llm/providers/anthropic/request.rs b/server-new/src/llm/providers/anthropic/request.rs new file mode 100644 index 0000000..cf95bee --- /dev/null +++ b/server-new/src/llm/providers/anthropic/request.rs @@ -0,0 +1,163 @@ +use serde::Serialize; + +use crate::llm::types::LlmMessage; + +pub fn build_anthropic_messages<'a>( + messages: &'a [LlmMessage], +) -> (Vec>, Option<&'a str>) { + let system_prompt = messages.iter().rev().find_map(|message| { + let LlmMessage::System(msg) = message else { + return None; + }; + Some(msg.as_str()) + }); + + let anthropic_messages: Vec = messages + .iter() + .filter_map(|message| { + let mut content_blocks = Vec::new(); + match message { + LlmMessage::User(user_message) => { + if !user_message.text.is_empty() { + content_blocks.push(AnthropicContentBlock::Text { + text: &user_message.text, + }); + } + // if let Some(ref files) = user_message.files { + // content_blocks.extend(files.iter().map(|file| match file.file_type { + // ChatRsFileType::Text => AnthropicContentBlock::Document { + // title: &file.name, + // source: AnthropicSource::Text { + // data: &file.content, + // media_type: "text/plain", + // }, + // }, + // ChatRsFileType::Image => AnthropicContentBlock::Image { + // source: AnthropicSource::Base64 { + // data: &file.content, + // media_type: &file.content_type, + // }, + // }, + // ChatRsFileType::Pdf => AnthropicContentBlock::Document { + // title: &file.name, + // source: AnthropicSource::Base64 { + // data: &file.content, + // media_type: "application/pdf", + // }, + // }, + // })); + // } + Some(AnthropicMessage { + role: "user", + content: content_blocks, + }) + } + LlmMessage::Assistant(assistant_message) => { + if !assistant_message.text.is_empty() { + content_blocks.push(AnthropicContentBlock::Text { + text: &assistant_message.text, + }); + } + // if let Some(ref tool_calls) = assistant_message.tool_calls { + // content_blocks.extend(tool_calls.iter().map(|tc| { + // AnthropicContentBlock::ToolUse { + // id: &tc.id, + // name: &tc.tool_name, + // input: &tc.parameters, + // } + // })); + // } + Some(AnthropicMessage { + role: "assistant", + content: content_blocks, + }) + } + // LlmMessage::Tool(result) => { + // content_blocks.push(AnthropicContentBlock::ToolResult { + // tool_use_id: &result.tool_call_id, + // content: &result.content, + // }); + // Some(AnthropicMessage { + // role: "user", + // content: content_blocks, + // }) + // } + _ => None, + } + }) + .collect(); + + (anthropic_messages, system_prompt) +} + +// pub fn build_anthropic_tools<'a>(tools: &'a [LlmTool]) -> Vec> { +// tools +// .iter() +// .map(|tool| AnthropicTool { +// name: &tool.name, +// description: &tool.description, +// input_schema: &tool.input_schema, +// }) +// .collect() +// } + +/// Anthropic API request message +#[derive(Debug, Serialize)] +pub struct AnthropicMessage<'a> { + pub role: &'a str, + pub content: Vec>, +} + +/// Anthropic API request body +#[derive(Debug, Default, Serialize)] +pub struct AnthropicRequest<'a> { + pub model: &'a str, + pub messages: Vec>, + pub max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>>, +} + +/// Anthropic tool definition +#[derive(Debug, Serialize)] +pub struct AnthropicTool<'a> { + name: &'a str, + description: &'a str, + input_schema: &'a serde_json::Value, +} + +/// Anthropic content block for messages +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AnthropicContentBlock<'a> { + Text { text: &'a str }, + // Image { + // source: AnthropicSource<'a>, + // }, + // Document { + // title: &'a str, + // source: AnthropicSource<'a>, + // }, + // ToolUse { + // id: &'a str, + // name: &'a str, + // input: &'a HashMap, + // }, + // ToolResult { + // tool_use_id: &'a str, + // content: &'a str, + // }, +} + +// #[derive(Debug, Serialize)] +// #[serde(tag = "type", rename_all = "lowercase")] +// pub enum AnthropicSource<'a> { +// Base64 { data: &'a str, media_type: &'a str }, +// Text { data: &'a str, media_type: &'a str }, +// } diff --git a/server-new/src/llm/providers/anthropic/response.rs b/server-new/src/llm/providers/anthropic/response.rs new file mode 100644 index 0000000..42e6f3b --- /dev/null +++ b/server-new/src/llm/providers/anthropic/response.rs @@ -0,0 +1,206 @@ +use serde::Deserialize; + +use crate::llm::{ + error::LlmStreamChunkError, + interface::{LlmStreamChunk, LlmStreamChunkResult}, + types::LlmUsage, +}; + +/// Parse an Anthropic SSE event. +pub fn parse_anthropic_event( + event: AnthropicStreamEvent, + // tools: Option<&Vec>, + // tool_calls: &mut Vec, +) -> Option { + match event { + AnthropicStreamEvent::MessageStart { message } => { + if let Some(usage) = message.usage { + return Some(Ok(LlmStreamChunk::Usage(usage.into()))); + } + } + AnthropicStreamEvent::ContentBlockStart { content_block, .. } => match content_block { + AnthropicResponseContentBlock::Text { text } => { + return Some(Ok(LlmStreamChunk::Text(text))); + } + AnthropicResponseContentBlock::ToolUse { .. } => { + // tool_calls.push(AnthropicStreamToolCall { + // id, + // index, + // name, + // input: String::with_capacity(100), + // }); + } + }, + AnthropicStreamEvent::ContentBlockDelta { delta, .. } => match delta { + AnthropicDelta::TextDelta { text } => { + return Some(Ok(LlmStreamChunk::Text(text))); + } + AnthropicDelta::InputJsonDelta { .. } => { + // if let Some(tool_call) = tool_calls.iter_mut().find(|tc| tc.index == index) { + // tool_call.input.push_str(&partial_json); + // let chunk = LlmStreamChunk::PendingToolCall(LlmPendingToolCall { + // index, + // tool_name: tool_call.name.clone(), + // }); + // return Some(Ok(chunk)); + // } + } + }, + AnthropicStreamEvent::ContentBlockStop { .. } => { + // if let Some(llm_tools) = tools { + // if let Some(tc) = tool_calls + // .iter() + // .position(|tc| tc.index == index) + // .map(|i| tool_calls.swap_remove(i)) + // { + // if let Some(tool_call) = tc.convert(llm_tools) { + // let chunk = LlmStreamChunk::ToolCalls(vec![tool_call]); + // return Some(Ok(chunk)); + // } + // } + // } + } + AnthropicStreamEvent::MessageDelta { usage: Some(usage) } => { + return Some(Ok(LlmStreamChunk::Usage(usage.into()))); + } + AnthropicStreamEvent::Error { error } => { + let error_msg = format!("{}: {}", error.error_type, error.message); + return Some(Err(LlmStreamChunkError::Provider(error_msg))); + } + _ => {} // Ignore other events (ping, message_stop) + } + None +} + +/// Anthropic API response content block +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(unused)] +pub enum AnthropicResponseContentBlock { + Text { text: String }, + ToolUse { id: String, name: String }, +} + +/// Anthropic API response usage +#[derive(Debug, Deserialize)] +pub struct AnthropicUsage { + input_tokens: Option, + output_tokens: Option, +} + +impl From for LlmUsage { + fn from(usage: AnthropicUsage) -> Self { + LlmUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cost: None, + } + } +} + +/// Anthropic API response +#[derive(Debug, Deserialize)] +pub struct AnthropicResponse { + pub content: Vec, + pub usage: Option, +} + +/// Anthropic stream response (message start) +#[derive(Debug, Deserialize)] +pub struct AnthropicStreamResponse { + // id: String, + // #[serde(rename = "type")] + // message_type: String, + // role: String, + // content: Vec, + // model: String, + // stop_reason: Option, + // stop_sequence: Option, + usage: Option, +} + +/// Anthropic streaming event types +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(unused)] +pub enum AnthropicStreamEvent { + MessageStart { + message: AnthropicStreamResponse, + }, + ContentBlockStart { + index: usize, + content_block: AnthropicResponseContentBlock, + }, + ContentBlockDelta { + index: usize, + delta: AnthropicDelta, + }, + ContentBlockStop { + index: usize, + }, + MessageDelta { + // delta: AnthropicMessageDelta, + usage: Option, + }, + MessageStop, + Ping, + Error { + error: AnthropicError, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(unused)] +pub enum AnthropicDelta { + TextDelta { text: String }, + InputJsonDelta { partial_json: String }, +} + +// #[derive(Debug, Deserialize)] +// pub struct AnthropicMessageDelta { +// stop_reason: Option, +// stop_sequence: Option, +// } + +#[derive(Debug, Deserialize)] +pub struct AnthropicError { + #[serde(rename = "type")] + error_type: String, + message: String, +} + +// /// Helper struct for tracking streaming tool calls +// #[derive(Debug)] +// pub struct AnthropicStreamToolCall { +// /// Anthropic tool call ID +// id: String, +// /// Index of the tool call in the message +// index: usize, +// /// Name of the tool +// name: String, +// /// Partial input parameters (JSON stringified) +// input: String, +// } + +// impl AnthropicStreamToolCall { +// /// Convert Anthropic tool call format to ChatRsToolCall +// fn convert(self, llm_tools: &[LlmTool]) -> Option { +// let input = if self.input.trim().is_empty() { +// "{}" +// } else { +// &self.input +// }; +// let parameters = serde_json::from_str(input).ok()?; +// llm_tools +// .iter() +// .find(|tool| tool.name == self.name) +// .map(|tool| ChatRsToolCall { +// id: self.id, +// tool_id: tool.tool_id, +// tool_name: self.name, +// tool_type: tool.tool_type, +// parameters, +// }) +// } +// } diff --git a/server-new/src/llm/providers/lorem.rs b/server-new/src/llm/providers/lorem.rs new file mode 100644 index 0000000..633ac7e --- /dev/null +++ b/server-new/src/llm/providers/lorem.rs @@ -0,0 +1,115 @@ +//! Lorem ipsum LLM provider (for testing) + +use std::{pin::Pin, time::Duration}; + +use futures::Stream; +use tokio::time::{Interval, interval}; + +use crate::llm::{ + error::LlmStreamChunkError, + interface::*, + types::{LlmChatRequest, LlmPrompt, LlmUsage}, +}; + +/// A test/dummy provider that streams 'lorem ipsum...' and emits test errors during the stream +#[derive(Debug, Clone)] +pub struct LoremProvider { + pub interval: u32, +} + +impl LoremProvider { + pub fn new() -> Self { + LoremProvider { interval: 400 } + } +} + +struct LoremStream { + words: Vec<&'static str>, + index: usize, + interval: Interval, +} +impl Stream for LoremStream { + type Item = LlmStreamChunkResult; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + if self.index >= self.words.len() { + return std::task::Poll::Ready(None); + } + + match self.interval.poll_tick(cx) { + std::task::Poll::Ready(_) => { + let word = self.words[self.index]; + self.index += 1; + if self.index == 0 || !self.index.is_multiple_of(10) { + std::task::Poll::Ready(Some(Ok(LlmStreamChunk::Text(word.to_owned())))) + } else { + std::task::Poll::Ready(Some(Err(LlmStreamChunkError::Provider( + "Test error".into(), + )))) + } + } + std::task::Poll::Pending => std::task::Poll::Pending, + } + } +} + +impl LlmProvider for LoremProvider { + fn prompt<'r>(&'r self, prompt: LlmPrompt<'r>) -> LlmPromptResponse<'r> { + let response = LlmResponse { + text: "Lorem ipsum".into(), + usage: LlmUsage { + input_tokens: Some((prompt.text.len() / 4) as i32), + output_tokens: Some(4), + ..Default::default() + }, + ..Default::default() + }; + + Box::pin(async { Ok(response) }) + } + + fn stream_chat<'r>(&'r self, _request: LlmChatRequest<'r>) -> LlmStreamingResponse<'r> { + let lorem_words = vec![ + "Lorem ipsum ", + "dolor sit ", + "amet, consectetur ", + "adipiscing elit, ", + "sed do", + " eiusmod tempor", + " incididunt ut", + " labore et", + " dolore magna ", + "aliqua. Ut ", + "enim ad ", + "minim veniam,", + " quis nostrud", + " exercitation ullamco", + " laboris nisi ", + "ut aliquip ", + "ex ea ", + "commodo consequat. ", + "Duis aute ", + "irure dolor ", + "in reprehenderit ", + "in voluptate ", + "velit esse ", + "cillum dolore ", + "eu fugiat ", + "nulla pariatur.", + ]; + + Box::pin(async move { + let stream: LlmStream = Box::pin(LoremStream { + words: lorem_words, + index: 0, + interval: interval(Duration::from_millis(self.interval.into())), + }); + tokio::time::sleep(Duration::from_millis(1000)).await; // Simulate initial request latency + + Ok((stream, LlmResponseMeta::default())) + }) + } +} diff --git a/server-new/src/llm/providers/mod.rs b/server-new/src/llm/providers/mod.rs new file mode 100644 index 0000000..10c2227 --- /dev/null +++ b/server-new/src/llm/providers/mod.rs @@ -0,0 +1,10 @@ +mod anthropic; +mod lorem; +mod ollama; +mod openai; +mod utils; + +pub use anthropic::AnthropicProvider; +pub use lorem::LoremProvider; +pub use ollama::OllamaProvider; +pub use openai::{OpenAIProvider, OpenAIProviderConfig}; diff --git a/server-new/src/llm/providers/ollama/mod.rs b/server-new/src/llm/providers/ollama/mod.rs new file mode 100644 index 0000000..a38be20 --- /dev/null +++ b/server-new/src/llm/providers/ollama/mod.rs @@ -0,0 +1,126 @@ +//! Ollama LLM provider + +use futures::StreamExt; + +use crate::llm::{ + error::LlmRequestError, + interface::*, + providers::utils, + types::{LlmChatRequest, LlmPrompt}, +}; + +mod request; +mod response; + +use {request::*, response::*}; + +const CHAT_API_URL: &str = "/api/chat"; +const COMPLETION_API_URL: &str = "/api/generate"; + +/// Ollama chat provider +#[derive(Debug, Clone)] +pub struct OllamaProvider { + client: reqwest::Client, + base_url: String, +} + +impl OllamaProvider { + pub fn new(http_client: &reqwest::Client, base_url: &str) -> Self { + Self { + client: http_client.clone(), + base_url: base_url.trim_end_matches('/').to_string(), + } + } +} + +impl LlmProvider for OllamaProvider { + fn prompt<'r>(&'r self, prompt: LlmPrompt<'r>) -> LlmPromptResponse<'r> { + let ollama_options = OllamaOptions { + temperature: prompt.options.temperature, + num_predict: prompt.options.max_tokens, + ..Default::default() + }; + let request = OllamaCompletionRequest { + model: &prompt.options.model, + prompt: prompt.text, + stream: Some(false), + options: Some(ollama_options), + }; + + Box::pin(async move { + let res: OllamaCompletionResponse = utils::llm_api_request( + self.client + .post(format!("{}{}", self.base_url, COMPLETION_API_URL)) + .json(&request), + "Ollama", + None, + ) + .await? + .json() + .await?; + + if res.response.is_empty() { + return Err(LlmRequestError::NoContent); + } + + Ok(LlmResponse { + usage: res.usage().unwrap_or_default(), + text: res.response, + ..Default::default() + }) + }) + } + + fn stream_chat<'r>(&'r self, req: LlmChatRequest<'r>) -> LlmStreamingResponse<'r> { + let ollama_messages = build_ollama_messages(req.messages); + // let ollama_tools = tools.as_ref().map(|t| build_ollama_tools(t)); + let ollama_options = OllamaOptions { + temperature: req.options.temperature, + num_predict: req.options.max_tokens, + ..Default::default() + }; + let request = OllamaChatRequest { + model: &req.options.model, + messages: ollama_messages, + // tools: ollama_tools, + stream: Some(true), + options: Some(ollama_options), + }; + + Box::pin(async move { + let response = utils::llm_api_request( + self.client + .post(format!("{}{}", self.base_url, CHAT_API_URL)) + .json(&request), + "Ollama", + None, + ) + .await?; + let stream = async_stream::stream! { + let mut json_stream = utils::get_json_events(response); + // let mut tool_calls: Vec = Vec::new(); + while let Some(event) = json_stream.next().await { + match event { + Ok(event) => { + for chunk in parse_ollama_event(event) { + yield Ok(chunk); + } + } + Err(e) => yield Err(e), + } + } + // if !tool_calls.is_empty() { + // if let Some(llm_tools) = tools { + // let converted = tool_calls + // .into_iter() + // .filter_map(|tc| tc.function.convert(&llm_tools)) + // .collect(); + // yield Ok(LlmStreamChunk::ToolCalls(converted)); + // } + // } + }; + + Ok((stream.boxed(), LlmResponseMeta::default())) + }) + } +} diff --git a/server-new/src/llm/providers/ollama/request.rs b/server-new/src/llm/providers/ollama/request.rs new file mode 100644 index 0000000..5ccc349 --- /dev/null +++ b/server-new/src/llm/providers/ollama/request.rs @@ -0,0 +1,148 @@ +//! Ollama API request structures + +use serde::Serialize; +use serde_with::skip_serializing_none; + +use crate::llm::types::LlmMessage; + +/// Convert LlmMessages to Ollama messages +pub fn build_ollama_messages(messages: &[LlmMessage]) -> Vec> { + messages + .iter() + .map(|message| match message { + LlmMessage::User(user_message) => { + // let images = user_message.files.as_ref().map(|files| { + // files + // .iter() + // .filter_map(|file| match file.file_type { + // ChatRsFileType::Image => Some(file.content.as_str()), + // _ => None, + // }) + // .collect::>() + // }); + OllamaMessage { + role: "user", + content: &user_message.text, + // images, + ..Default::default() + } + } + LlmMessage::Assistant(assistant_message) => { + // let tool_calls = assistant_message.tool_calls.as_ref().map(|tool_calls| { + // tool_calls + // .iter() + // .map(|tc| OllamaToolCall { + // function: OllamaFunction { + // name: &tc.tool_name, + // arguments: &tc.parameters, + // }, + // }) + // .collect() + // }); + OllamaMessage { + role: "assistant", + content: &assistant_message.text, + // tool_calls, + ..Default::default() + } + } + LlmMessage::System(text) => OllamaMessage { + role: "system", + content: text, + ..Default::default() + }, + // LlmMessage::Tool(result) => OllamaMessage { + // role: "tool", + // content: &result.content, + // tool_name: Some(&result.tool_name), + // ..Default::default() + // }, + }) + .collect() +} + +// /// Convert LlmTools to Ollama tools +// pub fn build_ollama_tools(tools: &[LlmTool]) -> Vec> { +// tools +// .iter() +// .map(|tool| OllamaTool { +// r#type: "function", +// function: OllamaToolSpec { +// name: &tool.name, +// description: &tool.description, +// parameters: &tool.input_schema, +// }, +// }) +// .collect() +// } + +/// Ollama chat request structure +#[skip_serializing_none] +#[derive(Debug, Default, Serialize)] +pub struct OllamaChatRequest<'a> { + pub model: &'a str, + pub messages: Vec>, + // pub tools: Option>>, + pub stream: Option, + pub options: Option, +} + +/// Ollama completion request structure +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct OllamaCompletionRequest<'a> { + pub model: &'a str, + pub prompt: &'a str, + pub stream: Option, + pub options: Option, +} + +/// Ollama chat message +#[skip_serializing_none] +#[derive(Debug, Default, Serialize)] +pub struct OllamaMessage<'a> { + pub role: &'a str, + pub content: &'a str, + pub images: Option>, + // pub tool_calls: Option>>, + pub tool_name: Option<&'a str>, +} + +// /// Ollama tool call in a message +// #[derive(Debug, Serialize)] +// pub struct OllamaToolCall<'a> { +// pub function: OllamaFunction<'a>, +// } + +// /// Ollama tool function +// #[derive(Debug, Serialize)] +// pub struct OllamaFunction<'a> { +// pub name: &'a str, +// pub arguments: &'a ToolParameters, +// } + +// /// Ollama tool definition +// #[derive(Debug, Serialize)] +// pub struct OllamaTool<'a> { +// pub r#type: &'a str, +// pub function: OllamaToolSpec<'a>, +// } + +// /// Ollama tool specification +// #[derive(Debug, Serialize)] +// pub struct OllamaToolSpec<'a> { +// pub name: &'a str, +// pub description: &'a str, +// pub parameters: &'a serde_json::Value, +// } + +/// Ollama model options +#[skip_serializing_none] +#[derive(Debug, Default, Serialize)] +pub struct OllamaOptions { + pub temperature: Option, + pub num_predict: Option, // Ollama's equivalent to max_tokens + pub top_p: Option, + pub top_k: Option, + pub seed: Option, +} diff --git a/server-new/src/llm/providers/ollama/response.rs b/server-new/src/llm/providers/ollama/response.rs new file mode 100644 index 0000000..7f73996 --- /dev/null +++ b/server-new/src/llm/providers/ollama/response.rs @@ -0,0 +1,148 @@ +//! Ollama API response structures + +use serde::Deserialize; + +use crate::llm::{interface::LlmStreamChunk, types::LlmUsage}; + +/// Parse Ollama streaming event into LlmStreamChunks +pub fn parse_ollama_event( + event: OllamaStreamEvent, + // tool_calls: &mut Vec, +) -> impl Iterator { + // Handle usage stats + let usage = event.usage().map(LlmStreamChunk::Usage); + + // Handle text response + let text = (!event.message.content.is_empty()) + .then_some(event.message.content) + .map(LlmStreamChunk::Text); + + [text, usage].into_iter().flatten() + + // Handle tool calls in the message + // if !event.message.tool_calls.is_empty() { + // for (index, tc) in event.message.tool_calls.iter().enumerate() { + // let tool_call = LlmPendingToolCall { + // index, + // tool_name: tc.function.name.clone(), + // }; + // chunks.push(Ok(LlmStreamChunk::PendingToolCall(tool_call))); + // } + // tool_calls.extend(event.message.tool_calls); + // } +} + +/// Ollama chat response (streaming) +#[derive(Debug, Deserialize)] +pub struct OllamaStreamEvent { + // pub model: String, + // pub created_at: String, + pub message: OllamaMessageResponse, + pub done: bool, + // #[serde(default)] + // pub done_reason: Option, + // #[serde(default)] + // pub total_duration: Option, + // #[serde(default)] + // pub load_duration: Option, + #[serde(default)] + pub prompt_eval_count: Option, + // #[serde(default)] + // pub prompt_eval_duration: Option, + #[serde(default)] + pub eval_count: Option, + // #[serde(default)] + // pub eval_duration: Option, +} + +/// Ollama completion response (non-streaming) +#[derive(Debug, Deserialize)] +pub struct OllamaCompletionResponse { + pub response: String, + // pub model: String, + // pub created_at: String, + // pub done: bool, + // #[serde(default)] + // pub done_reason: Option, + // #[serde(default)] + // pub total_duration: Option, + // #[serde(default)] + // pub load_duration: Option, + // #[serde(default)] + // pub prompt_eval_duration: Option, + // #[serde(default)] + // pub eval_duration: Option, + #[serde(default)] + pub prompt_eval_count: Option, + #[serde(default)] + pub eval_count: Option, +} + +/// Ollama message in response +#[derive(Debug, Deserialize)] +pub struct OllamaMessageResponse { + #[serde(default)] + pub content: String, + // pub role: String, + // #[serde(default)] + // pub tool_calls: Vec, +} + +// /// Ollama tool call in response +// #[derive(Debug, Deserialize)] +// pub struct OllamaToolCallResponse { +// pub function: OllamaFunctionResponse, +// } + +// /// Ollama tool function in response +// #[derive(Debug, Deserialize)] +// pub struct OllamaFunctionResponse { +// pub name: String, +// pub arguments: serde_json::Value, +// } + +// impl OllamaFunctionResponse { +// /// Convert to ChatRsToolCall if the tool exists in the provided tools +// pub fn convert(self, tools: &[LlmTool]) -> Option { +// let tool = tools.iter().find(|t| t.name == self.name)?; +// let parameters = serde_json::from_value(self.arguments).ok()?; + +// Some(ChatRsToolCall { +// id: uuid::Uuid::new_v4().to_string(), +// parameters, +// tool_id: tool.tool_id, +// tool_name: self.name, +// tool_type: tool.tool_type, +// }) +// } +// } + +impl OllamaCompletionResponse { + /// Convert usage to LlmUsage + pub fn usage(&self) -> Option { + if self.prompt_eval_count.is_some() || self.eval_count.is_some() { + Some(LlmUsage { + input_tokens: self.prompt_eval_count, + output_tokens: self.eval_count, + ..Default::default() + }) + } else { + None + } + } +} + +impl OllamaStreamEvent { + /// If last event in stream, convert usage to LlmUsage + pub fn usage(&self) -> Option { + if self.done && (self.prompt_eval_count.is_some() || self.eval_count.is_some()) { + Some(LlmUsage { + input_tokens: self.prompt_eval_count, + output_tokens: self.eval_count, + ..Default::default() + }) + } else { + None + } + } +} diff --git a/server-new/src/llm/providers/openai/mod.rs b/server-new/src/llm/providers/openai/mod.rs new file mode 100644 index 0000000..a22af2d --- /dev/null +++ b/server-new/src/llm/providers/openai/mod.rs @@ -0,0 +1,248 @@ +//! OpenAI (and OpenAI compatible) LLM provider + +use futures::StreamExt; + +use crate::{ + db::models::OpenAISubtype, + llm::{ + error::LlmRequestError, + interface::*, + providers::utils, + types::{LlmChatRequest, LlmPrompt}, + }, +}; + +mod request; +mod response; + +use {request::*, response::*}; + +const OPENAI_API_BASE_URL: &str = "https://api.openai.com/v1"; +const OPENROUTER_API_BASE_URL: &str = "https://openrouter.ai/api/v1"; + +impl OpenAISubtype { + fn name(&self) -> &'static str { + match self { + Self::OpenAI => "OpenAI", + Self::OpenRouter => "OpenRouter", + } + } + fn default_base_url(&self) -> &'static str { + match self { + Self::OpenAI => OPENAI_API_BASE_URL, + Self::OpenRouter => OPENROUTER_API_BASE_URL, + } + } + fn req_id_header(&self) -> &'static str { + match self { + OpenAISubtype::OpenAI => "X-Request-Id", + OpenAISubtype::OpenRouter => "X-Generation-Id", + } + } + fn use_max_completion_tokens(self) -> bool { + self == Self::OpenAI + } + fn include_store_false(self) -> bool { + self == Self::OpenAI + } + fn include_usage_stream_options(self) -> bool { + true + } +} + +/// Configuration for OpenAI-compatible providers. +#[derive(Debug, Clone)] +pub struct OpenAIProviderConfig { + subtype: OpenAISubtype, + api_key: String, + base_url: String, +} + +impl OpenAIProviderConfig { + pub fn new( + subtype: OpenAISubtype, + api_key: impl Into, + base_url: Option>, + ) -> Self { + Self { + subtype, + api_key: api_key.into(), + base_url: base_url + .map(Into::into) + .unwrap_or_else(|| subtype.default_base_url().to_owned()) + .trim_end_matches('/') + .to_owned(), + } + } +} + +/// OpenAI-compatible chat provider. +#[derive(Debug, Clone)] +pub struct OpenAIProvider { + client: reqwest::Client, + config: OpenAIProviderConfig, +} + +impl OpenAIProvider { + pub fn new(http_client: &reqwest::Client, config: OpenAIProviderConfig) -> Self { + Self { + client: http_client.clone(), + config, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct OpenAIRequestPolicy { + subtype: OpenAISubtype, +} + +impl OpenAIRequestPolicy { + fn new(subtype: OpenAISubtype) -> Self { + Self { subtype } + } + + fn max_tokens(self, max_tokens: Option) -> Option { + (!self.subtype.use_max_completion_tokens()) + .then_some(max_tokens) + .flatten() + } + + fn max_completion_tokens(self, max_tokens: Option) -> Option { + self.subtype + .use_max_completion_tokens() + .then_some(max_tokens) + .flatten() + } + + fn store(self) -> Option { + self.subtype.include_store_false().then_some(false) + } + + fn stream_options(self) -> Option { + self.subtype + .include_usage_stream_options() + .then_some(OpenAIStreamOptions { + include_usage: true, + }) + } +} + +impl LlmProvider for OpenAIProvider { + fn prompt<'r>(&'r self, prompt: LlmPrompt<'r>) -> LlmPromptResponse<'r> { + let policy = OpenAIRequestPolicy::new(self.config.subtype); + let request = OpenAIRequest { + model: &prompt.options.model, + messages: vec![OpenAIMessage { + role: "user", + content: Some(vec![OpenAIContent::Text { text: prompt.text }]), + ..Default::default() + }], + max_tokens: policy.max_tokens(prompt.options.max_tokens), + max_completion_tokens: policy.max_completion_tokens(prompt.options.max_tokens), + store: policy.store(), + ..Default::default() + }; + + Box::pin(async move { + let provider_name = self.config.subtype.name(); + let raw_response = utils::llm_api_request( + self.client + .post(format!("{}/chat/completions", self.config.base_url)) + .bearer_auth(&self.config.api_key) + .json(&request), + provider_name, + Some(self.config.subtype.req_id_header()), + ) + .await?; + let req_id = utils::extract_header(&raw_response, self.config.subtype.req_id_header()); + let mut response: OpenAIResponse = raw_response.json().await?; + + let text = response + .choices + .get_mut(0) + .and_then(|choice| choice.message.as_mut()) + .and_then(|message| message.content.take()) + .ok_or(LlmRequestError::NoContent)?; + + Ok(LlmResponse { + text, + usage: response.usage.map(Into::into).unwrap_or_default(), + meta: LlmResponseMeta::new(req_id), + }) + }) + } + + fn stream_chat<'r>(&'r self, req: LlmChatRequest<'r>) -> LlmStreamingResponse<'r> { + let policy = OpenAIRequestPolicy::new(self.config.subtype); + let openai_messages = build_openai_messages(req.messages); + // let openai_tools = tools.as_ref().map(|t| build_openai_tools(t)); + // + let request = OpenAIRequest { + model: &req.options.model, + messages: openai_messages, + max_tokens: policy.max_tokens(req.options.max_tokens), + max_completion_tokens: policy.max_completion_tokens(req.options.max_tokens), + temperature: req.options.temperature, + store: policy.store(), + stream: Some(true), + stream_options: policy.stream_options(), + // tools: openai_tools, + // modalities: options.modalities.as_ref(), + ..Default::default() + }; + let provider_name = self.config.subtype.name(); + + Box::pin(async move { + let response = utils::llm_api_request( + self.client + .post(format!("{}/chat/completions", self.config.base_url)) + .bearer_auth(&self.config.api_key) + .json(&request), + provider_name, + Some(self.config.subtype.req_id_header()), + ) + .await?; + let req_id = utils::extract_header(&response, self.config.subtype.req_id_header()); + + let stream = async_stream::stream! { + let mut sse_event_stream = utils::get_sse_events(response); + // let mut tool_calls: Vec = Vec::new(); + while let Some(event) = sse_event_stream.next().await { + match event { + Ok(event) => { + for chunk in parse_openai_event(event) { + yield chunk; + } + } + Err(e) => yield Err(e), + } + } + // if !tool_calls.is_empty() { + // if let Some(llm_tools) = tools { + // let converted = tool_calls + // .into_iter() + // .filter_map(|tc| tc.convert(&llm_tools)) + // .collect(); + // yield Ok(LlmStreamChunk::ToolCalls(converted)); + // } + // } + }; + + Ok((stream.boxed(), LlmResponseMeta::new(req_id))) + }) + } + + // async fn list_models(&self) -> Result, LlmError> { + // let models = models::ModelsDevService::new(&self.redis, &self.client) + // .list_models({ + // match self.base_url.as_str() { + // OPENROUTER_API_BASE_URL => models::ModelsDevServiceProvider::OpenRouter, + // _ => models::ModelsDevServiceProvider::OpenAI, + // } + // }) + // .await?; + + // Ok(models) + // } +} diff --git a/server-new/src/llm/providers/openai/request.rs b/server-new/src/llm/providers/openai/request.rs new file mode 100644 index 0000000..afa8043 --- /dev/null +++ b/server-new/src/llm/providers/openai/request.rs @@ -0,0 +1,193 @@ +use serde::Serialize; + +use crate::llm::{ + providers::utils, + types::{LlmFileType, LlmMessage}, +}; + +pub fn build_openai_messages<'a>(messages: &'a [LlmMessage]) -> Vec> { + messages + .iter() + .map(|message| match message { + LlmMessage::User(user_message) => { + let mut content = Vec::new(); + if !user_message.text.is_empty() { + content.push(OpenAIContent::Text { + text: &user_message.text, + }); + } + if let Some(ref files) = user_message.files { + content.extend(files.iter().map(|file| match file.file_type { + LlmFileType::Text => OpenAIContent::Text { + text: &file.content, + }, + LlmFileType::Image => OpenAIContent::ImageUrl { + image_url: OpenAIImageUrl { + url: utils::create_data_uri(&file.content_type, &file.content), + }, + }, + LlmFileType::Pdf => OpenAIContent::File { + file: OpenAIFile { + file_data: utils::create_data_uri( + &file.content_type, + &file.content, + ), + filename: &file.name, + }, + }, + })); + } + OpenAIMessage { + role: "user", + content: Some(content), + ..Default::default() + } + } + LlmMessage::Assistant(assistant_message) => { + // let tool_calls = assistant_message.tool_calls.as_ref().map(|tc| { + // tc.iter() + // .map(|tc| OpenAIToolCall { + // id: &tc.id, + // tool_type: "function", + // function: OpenAIToolCallFunction { + // name: &tc.tool_name, + // arguments: serde_json::to_string(&tc.parameters) + // .unwrap_or_default(), + // }, + // }) + // .collect() + // }); + OpenAIMessage { + role: "assistant", + content: (!assistant_message.text.is_empty()).then(|| { + vec![OpenAIContent::Text { + text: &assistant_message.text, + }] + }), + // tool_calls, + ..Default::default() + } + } + LlmMessage::System(text) => OpenAIMessage { + role: "system", + content: Some(vec![OpenAIContent::Text { text }]), + ..Default::default() + }, + // LlmMessage::Tool(tool_result) => OpenAIMessage { + // role: "tool", + // content: Some(vec![OpenAIContent::Text { + // text: &tool_result.content, + // }]), + // tool_call_id: Some(&tool_result.tool_call_id), + // ..Default::default() + // }, + }) + .collect() +} + +// pub fn build_openai_tools<'a>(tools: &'a [LlmTool]) -> Vec> { +// tools +// .iter() +// .map(|tool| OpenAITool { +// tool_type: "function", +// function: OpenAIToolFunction { +// name: &tool.name, +// description: &tool.description, +// parameters: &tool.input_schema, +// strict: true, +// }, +// }) +// .collect() +// } + +/// OpenAI API request body +#[derive(Debug, Default, Serialize)] +pub struct OpenAIRequest<'a> { + pub model: &'a str, + pub messages: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>>, + // #[serde(skip_serializing_if = "Option::is_none")] + // pub modalities: Option<&'a Vec>, +} + +/// OpenAI API request stream options +#[derive(Debug, Serialize)] +pub struct OpenAIStreamOptions { + pub include_usage: bool, +} + +/// OpenAI API request message +#[derive(Debug, Default, Serialize)] +pub struct OpenAIMessage<'a> { + pub role: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>>, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OpenAIContent<'a> { + Text { text: &'a str }, + ImageUrl { image_url: OpenAIImageUrl }, + File { file: OpenAIFile<'a> }, +} + +#[derive(Debug, Serialize)] +pub struct OpenAIImageUrl { + url: String, +} + +#[derive(Debug, Serialize)] +pub struct OpenAIFile<'a> { + file_data: String, + filename: &'a str, +} + +/// OpenAI tool definition +#[derive(Debug, Serialize)] +pub struct OpenAITool<'a> { + #[serde(rename = "type")] + tool_type: &'a str, + function: OpenAIToolFunction<'a>, +} + +/// OpenAI tool function definition +#[derive(Debug, Serialize)] +pub struct OpenAIToolFunction<'a> { + name: &'a str, + description: &'a str, + strict: bool, + parameters: &'a serde_json::Value, +} + +/// OpenAI tool call in messages +#[derive(Debug, Serialize)] +pub struct OpenAIToolCall<'a> { + id: &'a str, + #[serde(rename = "type")] + tool_type: &'a str, + function: OpenAIToolCallFunction<'a>, +} + +/// OpenAI tool call function in messages +#[derive(Debug, Serialize)] +pub struct OpenAIToolCallFunction<'a> { + name: &'a str, + arguments: String, +} diff --git a/server-new/src/llm/providers/openai/response.rs b/server-new/src/llm/providers/openai/response.rs new file mode 100644 index 0000000..1c82819 --- /dev/null +++ b/server-new/src/llm/providers/openai/response.rs @@ -0,0 +1,179 @@ +use serde::Deserialize; + +use crate::llm::{ + interface::{LlmStreamChunk, LlmStreamChunkResult}, + types::LlmUsage, +}; + +/// Parse chunks from an OpenAI SSE event +pub fn parse_openai_event( + event: OpenAIStreamResponse, + // _tool_calls: &mut Vec, +) -> impl Iterator { + let OpenAIStreamResponse { mut choices, usage } = event; + let text = choices + .pop() + .and_then(|choice| choice.delta) + .and_then(|delta| delta.content) + .map(|text| Ok(LlmStreamChunk::Text(text))); + let usage = usage.map(|usage| Ok(LlmStreamChunk::Usage(usage.into()))); + + [text, usage].into_iter().flatten() + + // if let Some(delta) = event.choices.pop().and_then(|c| c.delta) { + // if let Some(text) = delta.content { + // chunks.push(Ok(LlmStreamChunk::Text(text))); + // } + // // if let Some(tool_calls_delta) = delta.tool_calls { + // // for tool_call_delta in tool_calls_delta { + // // if let Some(tc) = tool_calls + // // .iter_mut() + // // .find(|tc| tc.index == tool_call_delta.index) + // // { + // // if let Some(function_arguments) = tool_call_delta.function.arguments { + // // *tc.function.arguments.get_or_insert_default() += &function_arguments; + // // } + // // if let Some(ref tool_name) = tc.function.name { + // // let chunk = LlmStreamChunk::PendingToolCall(LlmPendingToolCall { + // // index: tool_call_delta.index, + // // tool_name: tool_name.clone(), + // // }); + // // chunks.push(Ok(chunk)); + // // } + // // } else { + // // if let Some(ref tool_name) = tool_call_delta.function.name { + // // let chunk = LlmStreamChunk::PendingToolCall(LlmPendingToolCall { + // // index: tool_call_delta.index, + // // tool_name: tool_name.clone(), + // // }); + // // chunks.push(Ok(chunk)); + // // } + // // tool_calls.push(tool_call_delta); + // // } + // // } + // // } + // // if let Some(images) = delta.images { + // // chunks.push(Ok(LlmStreamChunk::Images( + // // images + // // .into_iter() + // // .map(|image| LlmImage { + // // base64_url: image.image_url.url, + // // }) + // // .collect(), + // // ))); + // // } + // } + // if let Some(usage) = event.usage { + // chunks.push(Ok(LlmStreamChunk::Usage(usage.into()))); + // } +} + +/// OpenAI API response +#[derive(Debug, Deserialize)] +pub struct OpenAIResponse { + pub choices: Vec, + pub usage: Option, +} + +/// OpenAI API streaming response +#[derive(Debug, Deserialize)] +pub struct OpenAIStreamResponse { + choices: Vec, + usage: Option, +} + +/// OpenAI API response choice +#[derive(Debug, Deserialize)] +pub struct OpenAIChoice { + pub message: Option, + pub delta: Option, + // finish_reason: Option, +} + +/// OpenAI API response message +#[derive(Debug, Deserialize)] +pub struct OpenAIResponseMessage { + // role: String, + pub content: Option, +} + +/// OpenAI API streaming delta +#[derive(Debug, Deserialize)] +pub struct OpenAIResponseDelta { + // role: Option, + content: Option, + // tool_calls: Option>, + // /// OpenRouter images + // #[serde(skip_serializing_if = "Option::is_none")] + // pub images: Option>, +} + +// /// OpenAI streaming tool call +// #[derive(Debug, Deserialize)] +// pub struct OpenAIStreamToolCall { +// id: Option, +// index: usize, +// function: OpenAIStreamToolCallFunction, +// } + +// impl OpenAIStreamToolCall { +// /// Convert OpenAI tool call format to ChatRsToolCall, add tool ID +// pub fn convert(self, rs_chat_tools: &[LlmTool]) -> Option { +// let id = self.id?; +// let tool_name = self.function.name?; +// let parameters = serde_json::from_str(&self.function.arguments?).ok()?; +// rs_chat_tools +// .iter() +// .find(|tool| tool.name == tool_name) +// .map(|tool| ChatRsToolCall { +// id, +// tool_id: tool.tool_id, +// tool_name, +// tool_type: tool.tool_type, +// parameters, +// }) +// } +// } + +// /// OpenAI streaming tool call function +// #[derive(Debug, Deserialize)] +// struct OpenAIStreamToolCallFunction { +// name: Option, +// arguments: Option, +// } + +// /// OpenRouter image +// #[derive(Debug, Deserialize)] +// pub struct OpenRouterImage { +// // #[serde(rename = "type")] +// // pub image_type: String, +// pub image_url: OpenRouterImageData, +// } + +// /// OpenRouter image data +// #[derive(Debug, Deserialize)] +// pub struct OpenRouterImageData { +// /// Base64 data URL +// pub url: String, +// } + +/// OpenAI API response usage +#[derive(Debug, Deserialize)] +pub struct OpenAIUsage { + prompt_tokens: Option, + completion_tokens: Option, + /// OpenRouter cost + cost: Option, + /// LLM Gateway cost + cost_usd_total: Option, +} + +impl From for LlmUsage { + fn from(usage: OpenAIUsage) -> Self { + LlmUsage { + input_tokens: usage.prompt_tokens, + output_tokens: usage.completion_tokens, + cost: usage.cost.or(usage.cost_usd_total), + } + } +} diff --git a/server-new/src/llm/providers/utils.rs b/server-new/src/llm/providers/utils.rs new file mode 100644 index 0000000..343ce12 --- /dev/null +++ b/server-new/src/llm/providers/utils.rs @@ -0,0 +1,88 @@ +//! Utilities for working with LLM requests and responses + +use futures::TryStreamExt; +use serde::de::DeserializeOwned; +use tokio_stream::{Stream, StreamExt}; +use tokio_util::{ + codec::{FramedRead, LinesCodec}, + io::StreamReader, +}; + +use crate::llm::error::{LlmRequestError, LlmStreamChunkError}; + +/// Max allowed length of stream lines (5 KB) +const MAX_LINE_LEN: usize = 5 * 1024; + +/// Create a data URI +pub fn create_data_uri(content_type: &str, b64_string: &str) -> String { + format!("data:{content_type};base64,{b64_string}") +} + +/// Get a stream of deserialized events from a provider SSE stream. +pub fn get_sse_events( + response: reqwest::Response, +) -> impl Stream> { + let stream_reader = StreamReader::new(response.bytes_stream().map_err(std::io::Error::other)); + let line_reader = FramedRead::new(stream_reader, LinesCodec::new_with_max_length(MAX_LINE_LEN)); + + line_reader.filter_map(|line_result| { + match line_result { + Ok(line) => { + if line.len() >= 6 && line.as_bytes().starts_with(b"data: ") { + let data = &line[6..]; // Skip "data: " prefix + if data.trim_start().is_empty() || data == "[DONE]" { + None // Skip empty lines and termination markers + } else { + Some(serde_json::from_str::(data).map_err(LlmStreamChunkError::Parsing)) + } + } else { + None // Ignore non-data lines + } + } + Err(e) => Some(Err(LlmStreamChunkError::Decoding(e))), + } + }) +} + +/// Get a stream of deserialized events from a provider JSON Lines stream (e.g. Ollama uses this format). +pub fn get_json_events( + response: reqwest::Response, +) -> impl Stream> { + let stream_reader = StreamReader::new(response.bytes_stream().map_err(std::io::Error::other)); + let line_reader = FramedRead::new(stream_reader, LinesCodec::new_with_max_length(MAX_LINE_LEN)); + line_reader.map(|line_result| match line_result { + Ok(line) => serde_json::from_str::(&line).map_err(LlmStreamChunkError::Parsing), + Err(e) => Err(LlmStreamChunkError::Decoding(e)), + }) +} + +/// Convenience function to make an API request to an LLM provider +pub async fn llm_api_request( + request: reqwest::RequestBuilder, + provider_name: &str, + req_id_header: Option<&str>, +) -> Result { + let response = request.send().await.map_err(|e| { + LlmRequestError::Provider(format!("{provider_name} request failed: {e}"), None) + })?; + if !response.status().is_success() { + let status = response.status(); + let request_id = req_id_header.and_then(|header| extract_header(&response, header)); + let error_text = response.text().await.unwrap_or_default(); + return Err(LlmRequestError::Provider( + format!("{provider_name} API error {status}: {error_text}",), + request_id, + )); + } + + Ok(response) +} + +/// Convenience function to extract a header (e.g. request ID) from an API response +pub fn extract_header(response: &reqwest::Response, header_name: &str) -> Option { + response + .headers() + .get(header_name) + .and_then(|h| h.to_str().ok()) + .map(str::to_owned) +} diff --git a/server-new/src/llm/types.rs b/server-new/src/llm/types.rs new file mode 100644 index 0000000..3d99635 --- /dev/null +++ b/server-new/src/llm/types.rs @@ -0,0 +1,102 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Generic LLM prompt +pub struct LlmPrompt<'r> { + pub text: &'r str, + pub options: &'r LlmChatOptions, +} + +/// Generic LLM chat request +pub struct LlmChatRequest<'r> { + pub messages: &'r [LlmMessage], + // tools: Option>, + pub options: &'r LlmChatOptions, +} + +/// Generic message type to send to LLM providers +pub enum LlmMessage { + User(LlmUserMessage), + Assistant(LlmAssistantMessage), + System(String), + // Tool(LlmToolResult), +} + +/// Generic chat options for all LLM providers +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct LlmChatOptions { + pub model: String, + pub temperature: Option, + pub max_tokens: Option, + // /// Only supported for OpenRouter + // #[serde(skip_serializing_if = "Option::is_none")] + // pub modalities: Option>, +} + +#[derive(Default)] +pub struct LlmUserMessage { + pub text: String, + pub files: Option>, +} + +pub struct LlmFileInput { + pub name: String, + pub file_type: LlmFileType, + pub content_type: String, + pub content: String, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum LlmFileType { + Text, + Image, + Pdf, +} + +pub struct LlmAssistantMessage { + pub text: String, + // pub tool_calls: Option>, +} + +/// Usage stats from the LLM provider +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, JsonSchema)] +pub struct LlmUsage { + pub input_tokens: Option, + pub output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, +} + +// pub struct LlmToolCall { +// pub id: String, +// pub tool_id: Uuid, +// pub name: String, +// pub tool_type: LlmToolType, +// pub arguments: serde_json::Value, +// } + +// /// Generic tool that can be passed to LLM providers +// #[derive(Debug)] +// pub struct LlmTool { +// pub name: String, +// pub description: String, +// pub input_schema: serde_json::Value, +// /// ID of the RsChat tool that this is derived from +// pub tool_id: Uuid, +// /// The type of tool this is derived from (internal, external API, etc.) +// pub tool_type: LlmToolType, +// } + +// #[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)] +// #[serde(rename_all = "snake_case")] +// pub enum LlmToolType { +// #[default] +// System, +// ExternalApi, +// } + +// pub struct LlmToolResult { +// pub tool_call_id: String, +// pub tool_name: String, +// pub content: String, +// } diff --git a/server-new/src/main.rs b/server-new/src/main.rs new file mode 100644 index 0000000..3106901 --- /dev/null +++ b/server-new/src/main.rs @@ -0,0 +1,96 @@ +use std::net::SocketAddr; + +use rs_chat_api::create_app; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Read .env file in debug mode + #[cfg(debug_assertions)] + dotenvy::dotenv().ok(); + + // Initialize logging + let (log_filter_handle, _log_guard) = init_logging(); + + // Build server + let app = create_app().await?; + let config = &app.state().config; + + // Set log level from config + let env_filter = tracing_subscriber::EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .parse(&config.server.log_level)?; + log_filter_handle.reload(env_filter)?; + + // Start listening for requests + let addr = SocketAddr::new(config.server.host, config.server.port); + let listener = tokio::net::TcpListener::bind(addr).await?; + tracing::info!("Server listening on http://{}...", listener.local_addr()?); + axum::serve( + listener, + app.router() + .into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal(app.shutdown())) + .await?; + + Ok(()) +} + +fn init_logging() -> ( + tracing_subscriber::reload::Handle, + tracing_appender::non_blocking::WorkerGuard, +) { + let init_log_level = std::env::var("RS_CHAT_SERVER__LOG_LEVEL").unwrap_or("info".into()); + let (writer, guard) = tracing_appender::non_blocking(std::io::stdout()); + let (filter_layer, filter_handle) = + tracing_subscriber::reload::Layer::new(EnvFilter::new(init_log_level)); + + if cfg!(debug_assertions) { + tracing_subscriber::registry() + .with(filter_layer) + .with(tracing_subscriber::fmt::layer().with_writer(writer)) + .init(); + } else { + let json_layer = tracing_subscriber::fmt::layer() + .json() + .flatten_event(true) + .with_current_span(false) + .with_writer(writer); + + tracing_subscriber::registry() + .with(filter_layer) + .with(json_layer) + .init(); + } + + (filter_handle, guard) +} + +/// Shutdown signal: listens for Ctrl-C, SIGINT, SIGTERM signals +async fn shutdown_signal(on_shutdown: impl Future + Send) { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to register Ctrl-C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to register SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("Received shutdown signal, shutting down server..."); + on_shutdown.await; +} diff --git a/server-new/src/plugins/auth.rs b/server-new/src/plugins/auth.rs new file mode 100644 index 0000000..80e4c76 --- /dev/null +++ b/server-new/src/plugins/auth.rs @@ -0,0 +1,76 @@ +use std::time::Duration; + +use anyhow::{Context, bail}; +use tower_sessions::{CachingSessionStore, Expiry, SessionManagerLayer, cookie}; +use tower_sessions_redis_store::RedisStore; + +use crate::{ + db::DbPool, + plugins::AxumPlugin, + services::auth::{ + encryption::Encryptor, oauth::OAuthService, session::AuthSessionService, + session_store::SessionDbStore, + }, +}; + +const REDIS_PREFIX: &str = "rs-chat:sess:"; +const CLEANUP_INTERVAL: Duration = Duration::from_mins(15); + +/// Add auth & session handling to the server. Sessions are stored in Postgres and cached in Redis. +pub fn plugin() -> AxumPlugin { + AxumPlugin::named("Auth") + .on_init(async |mut app| { + // Verify encryption key and build encryptor + let encryption_key = hex::decode(&app.config().auth.encryption_key) + .context("encryption_key must be hex value")?; + if encryption_key.len() != 32 { + bail!("encryption_key must be 32 bytes"); + } + let encryptor = Encryptor::new(&encryption_key)?; + app.insert(encryptor)?; + + // Build configured OAuth providers + let http_client = app.get::().context("no HTTP client")?; + let oauth_providers = OAuthService::build_provider_map(app.config(), http_client) + .context("build OAuth providers")?; + app.insert(oauth_providers)?; + + // Start session cleanup task + let db_pool = app.get::().context("no db pool")?.to_owned(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(CLEANUP_INTERVAL); + interval.tick().await; + + loop { + interval.tick().await; + tracing::debug!("Cleaning up auth sessions"); + if let Err(err) = AuthSessionService::session_cleanup(&db_pool).await { + tracing::warn!("Error cleaning up auth sessions: {err}"); + } + } + }); + + Ok(app) + }) + .on_setup(|app, router| { + // Session persistence + let redis_store = + RedisStore::with_prefix(app.state().redis.clone(), REDIS_PREFIX.to_owned()); + let db_store = SessionDbStore::new(app.state().db_pool.clone()); + let session_store = CachingSessionStore::new(redis_store, db_store); + + // Add session / cookie management to router + let session_layer = SessionManagerLayer::new(session_store) + .with_name(app.config().auth.cookie_name.clone()) + .with_expiry(Expiry::OnInactivity(cookie::time::Duration::minutes(15))) // default short session for login/OAuth + .with_private(cookie::Key::derive_from(&hex::decode( + &app.config().auth.encryption_key, + )?)) + .with_path("/") + .with_secure(true) + .with_http_only(true) + .with_same_site(cookie::SameSite::Lax); + + Ok(router.layer(session_layer)) + }) +} diff --git a/server-new/src/plugins/clients.rs b/server-new/src/plugins/clients.rs new file mode 100644 index 0000000..6e441c0 --- /dev/null +++ b/server-new/src/plugins/clients.rs @@ -0,0 +1,40 @@ +use std::time::Duration; + +use anyhow::Context; + +use crate::{plugins::AxumPlugin, services::stream::tinistream::TinistreamClient}; + +// Default timeout for HTTP requests +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Setup HTTP clients for interacting with LLMs and services +pub fn plugin() -> AxumPlugin { + AxumPlugin::named("Clients").on_init(async |mut app| { + // Main HTTP client for LLM provider and OAuth requests. + // No total request timeout to allow for long-lived streaming responses. + let http_client = reqwest::ClientBuilder::new() + .connect_timeout(TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + app.insert(http_client)?; + + // `tinistream` client with API key header + let tini_http_client = reqwest::ClientBuilder::new() + .connect_timeout(TIMEOUT) + .timeout(TIMEOUT) + .default_headers({ + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("X-API-KEY", app.config().services.streamer_api_key.parse()?); + headers + }) + .build()?; + let tinistream = TinistreamClient::new(tinistream_client::Client::new_with_client( + &app.config().services.streamer_url, + tini_http_client, + )); + tinistream.ping().await.context("connect to tinistream")?; + app.insert(tinistream)?; + + Ok(app) + }) +} diff --git a/server-new/src/plugins/database.rs b/server-new/src/plugins/database.rs new file mode 100644 index 0000000..94ca026 --- /dev/null +++ b/server-new/src/plugins/database.rs @@ -0,0 +1,64 @@ +use anyhow::Context; +use diesel::connection::InstrumentationEvent; +use diesel_async::{ + AsyncConnection, AsyncMigrationHarness, AsyncPgConnection, + pooled_connection::{AsyncDieselConnectionManager, ManagerConfig, deadpool::Pool}, +}; +use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; +use futures::TryFutureExt; + +use crate::{db::DbPool, plugins::AxumPlugin}; + +const MIGRATIONS: EmbeddedMigrations = diesel_migrations::embed_migrations!(); + +pub fn plugin() -> AxumPlugin { + AxumPlugin::named("Database") + .on_init(async |mut app| { + let manager = AsyncDieselConnectionManager::::new_with_config( + &app.config().database.url, + { + let mut config = ManagerConfig::default(); + config.recycling_method = + diesel_async::pooled_connection::RecyclingMethod::Fast; + config.custom_setup = Box::new(|url| { + Box::pin(AsyncPgConnection::establish(url).map_ok(|mut conn| { + conn.set_instrumentation(|ev: InstrumentationEvent<'_>| { + if let InstrumentationEvent::FinishQuery { query, error, .. } = ev { + if let Some(err) = error { + tracing::error!(?query, ?err, "Failed to execute query"); + } else { + tracing::debug!(?query); + } + }; + }); + conn + })) + }); + config + }, + ); + let pool: DbPool = Pool::builder(manager).build()?; + + let cxn = pool.get().await.context("failed to connect to database")?; + tracing::info!("Connected to database"); + match AsyncMigrationHarness::new(cxn).run_pending_migrations(MIGRATIONS) { + Ok(run_migrations) if run_migrations.is_empty() => { + tracing::info!("No migrations to run"); + } + Ok(run_migrations) => { + for migration in run_migrations { + tracing::info!("Migration run: '{migration}'"); + } + } + Err(err) => anyhow::bail!(format!("Migrations failed: {err}")), + }; + + app.insert(pool)?; + Ok(app) + }) + .on_shutdown(async |app| { + app.state().db_pool.close(); + tracing::info!("Shut down database pool"); + Ok(()) + }) +} diff --git a/server-new/src/plugins/logging.rs b/server-new/src/plugins/logging.rs new file mode 100644 index 0000000..94b13de --- /dev/null +++ b/server-new/src/plugins/logging.rs @@ -0,0 +1,44 @@ +use std::str::FromStr; + +use anyhow::Context; +use axum::{extract::Request, http::HeaderName}; +use tower::ServiceBuilder; +use tower_http::{ + request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}, + trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}, +}; +use tracing::Level; + +use crate::plugins::AxumPlugin; + +pub fn plugin() -> AxumPlugin { + AxumPlugin::named("Request logs").on_setup(|app, router| { + const LOG_LEVEL: Level = Level::INFO; + let request_id_header = HeaderName::from_str(&app.config().server.request_id_header) + .context("invalid request ID header")?; + + let trace_layer = TraceLayer::new_for_http() + .make_span_with({ + let id_header = request_id_header.clone(); + move |req: &Request| { + tracing::span!(LOG_LEVEL, "request", + method = %req.method(), + uri = %req.uri(), + id = req.headers().get(&id_header).and_then(|id| id.to_str().ok()), + ) + } + }) + .on_request(DefaultOnRequest::new().level(LOG_LEVEL)) + .on_response(DefaultOnResponse::new().level(LOG_LEVEL)); + + let logging_service = ServiceBuilder::new() + .layer(SetRequestIdLayer::new( + request_id_header.clone(), + MakeRequestUuid, + )) + .layer(trace_layer) + .layer(PropagateRequestIdLayer::new(request_id_header)); + + Ok(router.layer(logging_service)) + }) +} diff --git a/server-new/src/plugins/mod.rs b/server-new/src/plugins/mod.rs new file mode 100644 index 0000000..5c3e338 --- /dev/null +++ b/server-new/src/plugins/mod.rs @@ -0,0 +1,14 @@ +use axum_plugin::AdHocPlugin; + +use crate::{config::AppConfig, state::AppState}; + +pub mod auth; +pub mod clients; +pub mod database; +pub mod logging; +pub mod redis; +pub mod security; +pub mod web; + +/// Shared plugin type with correct state and config type parameters +pub type AxumPlugin = AdHocPlugin; diff --git a/server-new/src/plugins/redis.rs b/server-new/src/plugins/redis.rs new file mode 100644 index 0000000..57d2c55 --- /dev/null +++ b/server-new/src/plugins/redis.rs @@ -0,0 +1,40 @@ +use std::time::Duration; + +use anyhow::Context; +use fred::prelude::*; + +use crate::plugins::AxumPlugin; + +pub fn plugin() -> AxumPlugin { + AxumPlugin::named("Redis") + .on_init(async |mut app| { + let config = Config::from_url(&app.config().redis.url).context("parse Redis URL")?; + let timeout = Duration::from_secs(app.config().redis.timeout); + + let pool = Builder::from_config(config) + .with_connection_config(|c| { + c.connection_timeout = timeout; + c.internal_command_timeout = timeout; + c.tcp.nodelay = Some(true); + }) + .with_performance_config(|c| { + c.default_command_timeout = timeout; + }) + .build_pool(app.config().redis.pool_size)?; + + pool.init().await.context("failed to connect to Redis")?; + tracing::info!("Connected to Redis"); + app.insert(pool)?; + + Ok(app) + }) + .on_shutdown(async |app| { + if let Err(e) = app.state().redis.quit().await { + tracing::warn!("Error shutting down Redis pool: {e}"); + } else { + tracing::info!("Shut down Redis pool") + } + + Ok(()) + }) +} diff --git a/server-new/src/plugins/security.rs b/server-new/src/plugins/security.rs new file mode 100644 index 0000000..134f8a5 --- /dev/null +++ b/server-new/src/plugins/security.rs @@ -0,0 +1,31 @@ +use std::time::Duration; + +use axum::{extract::DefaultBodyLimit, http::StatusCode}; +use tower::ServiceBuilder; +use tower_http::timeout::TimeoutLayer; + +use crate::plugins::AxumPlugin; + +/// # Security plugin +/// Includes body limiter, request timeout, and security headers. +pub fn plugin() -> AxumPlugin { + AxumPlugin::named("Security").on_setup(|app, router| { + let security_headers = axum_helmet::Helmet::new() + .add(axum_helmet::CrossOriginOpenerPolicy::same_origin()) + .add(axum_helmet::CrossOriginResourcePolicy::same_origin()) + .add(axum_helmet::ReferrerPolicy::no_referrer()) + .add(axum_helmet::XContentTypeOptions::nosniff()) + .add(axum_helmet::XFrameOptions::same_origin()) + .into_layer()?; + + let service = ServiceBuilder::new() + .layer(DefaultBodyLimit::max(app.config().security.body_limit)) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + Duration::from_secs(app.config().security.request_timeout), + )) + .layer(security_headers); + + Ok(router.layer(service)) + }) +} diff --git a/server-new/src/plugins/web.rs b/server-new/src/plugins/web.rs new file mode 100644 index 0000000..1e9b125 --- /dev/null +++ b/server-new/src/plugins/web.rs @@ -0,0 +1,41 @@ +use axum::{ + Router, + http::{HeaderValue, header}, + middleware, + response::Response, +}; +use tower_http::services::{ServeDir, ServeFile}; + +use crate::plugins::AxumPlugin; + +/// Adds the website / static files to the router +pub fn plugin() -> AxumPlugin { + AxumPlugin::named("Web").on_setup(|app, router| { + let web_files_root = &app.config().server.web_root; + let web_service = ServeDir::new(web_files_root) + .fallback(ServeFile::new(format!("{web_files_root}/index.html"))); + let web_router = Router::new() + .fallback_service(web_service) + .layer(middleware::from_fn(cache_immutable_assets)); + + Ok(router.merge(web_router)) + }) +} + +/// Set cache headers for the website's immutable assets at `/assets/*` +async fn cache_immutable_assets( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + let is_immutable_asset = req.uri().path().starts_with("/assets/"); + + let mut response = next.run(req).await; + if response.status().is_success() && is_immutable_asset { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + } + + response +} diff --git a/server-new/src/services/auth/api_key.rs b/server-new/src/services/auth/api_key.rs new file mode 100644 index 0000000..a8c9833 --- /dev/null +++ b/server-new/src/services/auth/api_key.rs @@ -0,0 +1,74 @@ +use uuid::Uuid; + +use crate::{ + db::{DbPool, DbService, models::NewChatRsApiKey}, + services::auth::{ + encryption::Encryptor, + error::{AuthError, AuthResult}, + }, +}; + +const API_KEY_PREFIX: &str = "rs-chat-key"; +const API_KEY_HEADER_PREFIX: &str = "Bearer rs-chat-key|"; + +pub struct ApiKeyService<'r> { + encryptor: &'r Encryptor, +} + +impl<'r> ApiKeyService<'r> { + pub fn new(encryptor: &'r Encryptor) -> Self { + Self { encryptor } + } + + /// Build an API key string from the given ciphertext and nonce + fn build_api_key(ciphertext: &[u8], nonce: &[u8]) -> String { + format!( + "{API_KEY_PREFIX}|{}|{}", + hex::encode(nonce), + hex::encode(ciphertext) + ) + } + + /// Create an API key, and return its ID and the encrypted key + pub async fn create_api_key( + &self, + db: &mut DbService, + user_id: &Uuid, + name: &str, + ) -> AuthResult<(Uuid, String)> { + let key_id = db + .api_keys() + .create(NewChatRsApiKey { user_id, name }) + .await?; + let (ciphertext, nonce) = self.encryptor.encrypt_bytes(key_id.as_bytes())?; + + Ok((key_id, Self::build_api_key(&ciphertext, &nonce))) + } + + /// Validate the API key and get the user ID + pub async fn validate_api_key(&self, db: &DbPool, auth_header: &str) -> AuthResult { + let (nonce, ciphertext) = auth_header + .strip_prefix(API_KEY_HEADER_PREFIX) + .and_then(|s| s.split_once('|')) + .and_then(|(nonce_hex, cipher_hex)| { + hex::decode(nonce_hex) + .ok() + .zip(hex::decode(cipher_hex).ok()) + }) + .ok_or(AuthError::Unauthorized("invalid API key format"))?; + let api_key_id = self + .encryptor + .decrypt_bytes(&ciphertext, &nonce) + .map_err(|_| AuthError::Unauthorized("failed to decrypt API key")) + .and_then(|key_bytes| { + Uuid::from_slice(&key_bytes) + .map_err(|_| AuthError::Unauthorized("couldn't parse API key id")) + })?; + + let mut db = DbService::from_pool(db).await?; + match db.api_keys().find_by_id(&api_key_id).await? { + Some(api_key) => Ok(api_key.user_id), + None => Err(AuthError::Unauthorized("API key not found")), + } + } +} diff --git a/server-new/src/services/auth/encryption.rs b/server-new/src/services/auth/encryption.rs new file mode 100644 index 0000000..19b4256 --- /dev/null +++ b/server-new/src/services/auth/encryption.rs @@ -0,0 +1,76 @@ +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, Generate, KeyInit}, +}; + +/// Service for encrypting and decrypting secrets +pub struct Encryptor { + cipher: Aes256Gcm, +} + +type EncryptorResult = Result; + +/// Errors that can occur during encryption / decryption +#[derive(Debug, thiserror::Error)] +pub enum EncryptorError { + #[error("encryption error")] + Encryption, + #[error("decryption error")] + Decryption, + #[error("invalid key")] + InvalidKey, + #[error("invalid nonce")] + InvalidNonce, +} + +impl Encryptor { + pub fn new(key_bytes: &[u8]) -> EncryptorResult { + let cipher = + Aes256Gcm::new_from_slice(key_bytes).map_err(|_| EncryptorError::InvalidKey)?; + Ok(Self { cipher }) + } + + /// Encrypts a string using AES-256-GCM and returns the ciphertext and nonce. + pub fn encrypt_string(&self, plaintext: &str) -> EncryptorResult<(Vec, Vec)> { + let nonce = Nonce::generate(); + let ciphertext = self + .cipher + .encrypt(&nonce, plaintext.as_bytes()) + .map_err(|_| EncryptorError::Encryption)?; + + Ok((ciphertext, nonce.to_vec())) + } + + /// Encrypts a byte slice using AES-256-GCM and returns the ciphertext and nonce. + pub fn encrypt_bytes(&self, bytes: &[u8]) -> EncryptorResult<(Vec, Vec)> { + let nonce = Nonce::generate(); + let ciphertext = self + .cipher + .encrypt(&nonce, bytes) + .map_err(|_| EncryptorError::Encryption)?; + + Ok((ciphertext, nonce.to_vec())) + } + + /// Decrypts a string using AES-256-GCM. + pub fn decrypt_string(&self, ciphertext: &[u8], nonce: &[u8]) -> EncryptorResult { + let nonce = Nonce::try_from(nonce).map_err(|_| EncryptorError::InvalidNonce)?; + let plaintext = self + .cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| EncryptorError::Decryption)?; + + String::from_utf8(plaintext).map_err(|_| EncryptorError::Decryption) + } + + /// Decrypts a byte slice using AES-256-GCM. + pub fn decrypt_bytes(&self, ciphertext: &[u8], nonce: &[u8]) -> EncryptorResult> { + let nonce = Nonce::try_from(nonce).map_err(|_| EncryptorError::InvalidNonce)?; + let bytes = self + .cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| EncryptorError::Decryption)?; + + Ok(bytes) + } +} diff --git a/server-new/src/services/auth/error.rs b/server-new/src/services/auth/error.rs new file mode 100644 index 0000000..9060fdd --- /dev/null +++ b/server-new/src/services/auth/error.rs @@ -0,0 +1,35 @@ +use crate::{error::AppError, services::auth::encryption::EncryptorError}; + +pub type AuthResult = Result; + +/// Auth service errors +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("{0}")] + Unauthorized(&'static str), + #[error("{0}")] + BadRequest(&'static str), + #[error("OAuth error: {0}")] + OAuth(#[from] simple_oauth::SimpleOAuthError), + #[error("user not found")] + UserNotFound, + #[error("encryption error: {0}")] + Encryption(#[from] EncryptorError), + #[error("database error: {0}")] + Database(#[from] diesel::result::Error), + #[error("database pool error: {0}")] + DatabasePool(#[from] crate::db::DbPoolError), + #[error("session error: {0}")] + Session(#[from] tower_sessions::session::Error), +} + +// Conversion to HTTP API errors +impl From for AppError { + fn from(error: AuthError) -> Self { + match error { + AuthError::Unauthorized(reason) => Self::unauthorized(reason), + AuthError::BadRequest(reason) => Self::bad_request(reason), + error => Self::internal(error.into()), + } + } +} diff --git a/server-new/src/services/auth/mod.rs b/server-new/src/services/auth/mod.rs new file mode 100644 index 0000000..c296c18 --- /dev/null +++ b/server-new/src/services/auth/mod.rs @@ -0,0 +1,65 @@ +use crate::{ + config::AppConfig, + db::{DbService, models::ChatRsUser}, + services::auth::encryption::Encryptor, +}; +use uuid::Uuid; + +pub mod api_key; +pub mod encryption; +mod error; +pub mod oauth; +pub mod proxy; +pub mod session; +pub mod session_store; + +use error::{AuthError, AuthResult}; + +pub struct AuthService<'r> { + config: &'r AppConfig, + encryptor: &'r Encryptor, + oauth_providers: &'r oauth::OAuthProviderMap, +} + +impl<'r> AuthService<'r> { + pub fn new( + config: &'r AppConfig, + encryptor: &'r Encryptor, + oauth_providers: &'r oauth::OAuthProviderMap, + ) -> Self { + Self { + config, + encryptor, + oauth_providers, + } + } + + /// Get the user from the database with the given ID, or return + /// an internal error if not found + pub async fn get_user(&self, db: &mut DbService, id: &Uuid) -> AuthResult { + match db.users().find_by_id(id).await? { + None => Err(AuthError::UserNotFound), + Some(user) => Ok(user), + } + } + + /// Access session functions. + pub fn session(&self) -> session::AuthSessionService { + session::AuthSessionService::new(self.config.auth.session_length) + } + + /// Access OAuth functions + pub fn oauth(self) -> oauth::OAuthService<'r> { + oauth::OAuthService::new(self.config, self.oauth_providers) + } + + /// Access proxy auth functions + pub fn proxy(&self) -> proxy::ProxyService<'r> { + proxy::ProxyService::new(&self.config.auth.proxy) + } + + /// Access API key functions + pub fn api_keys(&self) -> api_key::ApiKeyService<'r> { + api_key::ApiKeyService::new(self.encryptor) + } +} diff --git a/server-new/src/services/auth/oauth.rs b/server-new/src/services/auth/oauth.rs new file mode 100644 index 0000000..bf1f2ca --- /dev/null +++ b/server-new/src/services/auth/oauth.rs @@ -0,0 +1,242 @@ +use std::collections::HashMap; + +use futures::future::BoxFuture; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use simple_oauth::{ + SimpleOAuthClient, SimpleOAuthError, SimpleOAuthProvider, + types::{OAuthCredentials, StandardTokenResponse, UserInfo}, +}; +use strum::Display; +use tower_sessions::Session; + +use crate::{ + config::AppConfig, + db::{ + DbService, + models::{ChatRsUser, NewChatRsUser, UpdateChatRsUser}, + }, + extractors::CurrentUser, + services::auth::{AuthError, AuthResult}, +}; + +mod discord; +mod github; +mod google; +mod oidc; + +pub use discord::DiscordOAuthConfig; +pub use github::GitHubOAuthConfig; +pub use google::GoogleOAuthConfig; +pub use oidc::OidcConfig; + +/// Map of configured OAuth providers stored in state +pub type OAuthProviderMap = HashMap)>; +/// Type of the OAuth client stored in state +pub type OAuthClient = SimpleOAuthClient>; + +/// Supported OAuth provider +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum OAuthProviderEnum { + Github, + Discord, + Google, + Oidc, +} + +/// Trait for all OAuth providers +pub trait OAuthProvider: Send + Sync { + fn get_inner_provider(&self) -> Box; + fn get_credentials(&self) -> OAuthCredentials; + fn find_linked_user<'a>( + &self, + db: &'a mut DbService, + user_info: &'a UserInfo, + ) -> BoxFuture<'a, AuthResult>>; + fn is_user_linked(&self, user: &ChatRsUser) -> bool; + fn create_update_user<'a>(&self, user_info: &'a UserInfo) -> UpdateChatRsUser<'a>; + fn create_new_user<'a>(&self, user_info: &'a UserInfo) -> NewChatRsUser<'a>; +} + +/// OAuth functions +pub struct OAuthService<'a> { + config: &'a AppConfig, + provider_map: &'a OAuthProviderMap, +} + +impl<'a> OAuthService<'a> { + const SESS_STATE_FIELD: &'static str = "oauth_state"; + const SESS_PKCE_FIELD: &'static str = "oauth_verifier"; + + pub(super) fn new(config: &'a AppConfig, provider_map: &'a OAuthProviderMap) -> Self { + Self { + config, + provider_map, + } + } + + fn oauth_provider( + &self, + provider: &OAuthProviderEnum, + ) -> AuthResult<(&OAuthClient, &dyn OAuthProvider)> { + let (client, provider) = self + .provider_map + .get(provider) + .ok_or_else(|| AuthError::BadRequest("unsupported OAuth provider"))?; + Ok((client, provider.as_ref())) + } + + fn get_redirect_url(&self, callback_path: &str) -> String { + format!("{}{}", &self.config.server.base_url, callback_path) + } + + pub async fn authorize_url( + &self, + provider: &OAuthProviderEnum, + callback_path: &str, + session: &Session, + ) -> AuthResult { + let (oauth_client, _) = self.oauth_provider(provider)?; + let auth = oauth_client + .authorize_url() + .redirect_url(self.get_redirect_url(callback_path)) + .build()?; + + session.insert(Self::SESS_STATE_FIELD, auth.state).await?; + session + .insert(Self::SESS_PKCE_FIELD, auth.pkce_verifier) + .await?; + + Ok(auth.url) + } + + pub async fn exchange_code( + &self, + provider: &OAuthProviderEnum, + callback_path: &str, + session: &Session, + code: &str, + state: &str, + ) -> AuthResult { + // Get saved state and code verifier from session + let initial_state = session + .remove::(Self::SESS_STATE_FIELD) + .await? + .ok_or(AuthError::Unauthorized("missing state in session"))?; + let pkce_verifier = session + .remove::(Self::SESS_PKCE_FIELD) + .await? + .ok_or(AuthError::Unauthorized("missing PKCE in session"))?; + + // Verify state + if initial_state != state { + return Err(AuthError::Unauthorized("state mismatch")); + } + + // Exchange code for token + let (oauth_client, _) = self.oauth_provider(provider)?; + let response = oauth_client + .exchange_code() + .redirect_url(self.get_redirect_url(callback_path)) + .code(code) + .pkce_verifier(pkce_verifier) + .build() + .await?; + + Ok(response) + } + + pub async fn get_user( + &self, + db: &mut DbService, + provider: &OAuthProviderEnum, + token: &StandardTokenResponse, + active_session: Option, + ) -> AuthResult { + // Get user info from provider + let (oauth_client, oauth_provider) = self.oauth_provider(provider)?; + let user_info = oauth_client.get_user_info(&token.access_token).await?; + + // Check for existing user, or create new user + let user = match oauth_provider.find_linked_user(db, &user_info).await? { + Some(existing_user) => { + if active_session.is_some_and(|sess| sess.user_id != existing_user.id) { + return Err(AuthError::Unauthorized("cannot switch users via OAuth")); + } else { + existing_user + } + } + None => match active_session { + None => { + let new_user = oauth_provider.create_new_user(&user_info); + db.users().create(new_user).await? + } + Some(sess) => match db.users().find_by_id(&sess.user_id).await? { + Some(user) if oauth_provider.is_user_linked(&user) => { + return Err(AuthError::BadRequest("already linked to this provider")); + } + Some(user) => { + // Link logged-in user to new provider + let update_user = oauth_provider.create_update_user(&user_info); + db.users().update(&user.id, update_user).await?; + user + } + None => { + return Err(AuthError::UserNotFound); + } + }, + }, + }; + + Ok(user) + } + + pub fn build_provider_map( + config: &crate::config::AppConfig, + http_client: &reqwest::Client, + ) -> Result { + use { + discord::DiscordProvider, github::GitHubProvider, google::GoogleProvider, + oidc::OidcProvider, + }; + + let mut map: OAuthProviderMap = HashMap::new(); + if let Some(ref c) = config.auth.github { + let provider = GitHubProvider::new(c); + let client = Self::build_oauth_client(http_client, &provider)?; + map.insert(OAuthProviderEnum::Github, (client, Box::new(provider))); + } + if let Some(ref c) = config.auth.discord { + let provider = DiscordProvider::new(c); + let client = Self::build_oauth_client(http_client, &provider)?; + map.insert(OAuthProviderEnum::Discord, (client, Box::new(provider))); + } + if let Some(ref c) = config.auth.google { + let provider = GoogleProvider::new(c); + let client = Self::build_oauth_client(http_client, &provider)?; + map.insert(OAuthProviderEnum::Google, (client, Box::new(provider))); + } + if let Some(ref c) = config.auth.oidc { + let provider = OidcProvider::new(c); + let client = Self::build_oauth_client(http_client, &provider)?; + map.insert(OAuthProviderEnum::Oidc, (client, Box::new(provider))); + } + + Ok(map) + } + + fn build_oauth_client( + http_client: &reqwest::Client, + provider: &impl OAuthProvider, + ) -> Result>, SimpleOAuthError> { + let oauth_client = SimpleOAuthClient::builder() + .provider(provider.get_inner_provider()) + .credentials(provider.get_credentials()) + .redirect_url("http://example.com/should-be-overridden") + .http_client(http_client) + .build()?; + Ok(oauth_client) + } +} diff --git a/server-new/src/services/auth/oauth/discord.rs b/server-new/src/services/auth/oauth/discord.rs new file mode 100644 index 0000000..df44f74 --- /dev/null +++ b/server-new/src/services/auth/oauth/discord.rs @@ -0,0 +1,74 @@ +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use simple_oauth::types::{OAuthCredentials, UserInfo}; + +use crate::{ + db::models::{ChatRsUser, NewChatRsUser, UpdateChatRsUser}, + services::auth::{AuthResult, oauth::OAuthProvider}, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DiscordOAuthConfig { + client_id: u64, + client_secret: String, +} + +pub struct DiscordProvider { + config: DiscordOAuthConfig, +} + +impl DiscordProvider { + pub fn new(config: &DiscordOAuthConfig) -> Self { + Self { + config: config.clone(), + } + } +} + +impl OAuthProvider for DiscordProvider { + fn get_inner_provider(&self) -> Box { + Box::new(simple_oauth::common::Discord) + } + + fn get_credentials(&self) -> OAuthCredentials { + OAuthCredentials::new( + self.config.client_id.to_string(), + &self.config.client_secret, + ) + } + + fn find_linked_user<'a>( + &self, + db: &'a mut crate::db::DbService, + user_data: &'a UserInfo, + ) -> BoxFuture<'a, AuthResult>> { + Box::pin(async move { + let user = db.users().find_by_discord_id(&user_data.id).await?; + Ok(user) + }) + } + + fn is_user_linked(&self, user: &ChatRsUser) -> bool { + user.discord_id.is_some() + } + + fn create_update_user<'a>(&self, user_data: &'a UserInfo) -> UpdateChatRsUser<'a> { + UpdateChatRsUser { + discord_id: Some(&user_data.id), + ..Default::default() + } + } + + fn create_new_user<'a>(&self, user_data: &'a UserInfo) -> NewChatRsUser<'a> { + NewChatRsUser { + discord_id: Some(&user_data.id), + name: user_data + .name + .as_deref() + .or(user_data.username.as_deref()) + .unwrap_or_default(), + avatar_url: user_data.avatar_url.as_deref(), + ..Default::default() + } + } +} diff --git a/server-new/src/services/auth/oauth/github.rs b/server-new/src/services/auth/oauth/github.rs new file mode 100644 index 0000000..e4885f2 --- /dev/null +++ b/server-new/src/services/auth/oauth/github.rs @@ -0,0 +1,74 @@ +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use simple_oauth::{ + SimpleOAuthProvider, + types::{OAuthCredentials, UserInfo}, +}; + +use crate::{ + db::models::{ChatRsUser, NewChatRsUser, UpdateChatRsUser}, + services::auth::{AuthResult, oauth::OAuthProvider}, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GitHubOAuthConfig { + client_id: String, + client_secret: String, +} + +pub struct GitHubProvider { + config: GitHubOAuthConfig, +} + +impl GitHubProvider { + pub fn new(config: &GitHubOAuthConfig) -> Self { + Self { + config: config.clone(), + } + } +} + +impl OAuthProvider for GitHubProvider { + fn get_inner_provider(&self) -> Box { + Box::new(simple_oauth::common::GitHub) + } + + fn get_credentials(&self) -> OAuthCredentials { + OAuthCredentials::new(&self.config.client_id, &self.config.client_secret) + } + + fn find_linked_user<'a>( + &self, + db: &'a mut crate::db::DbService, + user_data: &'a UserInfo, + ) -> BoxFuture<'a, AuthResult>> { + Box::pin(async move { + let user = db.users().find_by_github_id(&user_data.id).await?; + Ok(user) + }) + } + + fn is_user_linked(&self, user: &ChatRsUser) -> bool { + user.github_id.is_some() + } + + fn create_update_user<'a>(&self, user_data: &'a UserInfo) -> UpdateChatRsUser<'a> { + UpdateChatRsUser { + github_id: Some(&user_data.id), + ..Default::default() + } + } + + fn create_new_user<'a>(&self, user_data: &'a UserInfo) -> NewChatRsUser<'a> { + NewChatRsUser { + github_id: Some(&user_data.id), + name: user_data + .name + .as_deref() + .or(user_data.username.as_deref()) + .unwrap_or_default(), + avatar_url: user_data.avatar_url.as_deref(), + ..Default::default() + } + } +} diff --git a/server-new/src/services/auth/oauth/google.rs b/server-new/src/services/auth/oauth/google.rs new file mode 100644 index 0000000..8203c79 --- /dev/null +++ b/server-new/src/services/auth/oauth/google.rs @@ -0,0 +1,71 @@ +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use simple_oauth::types::{OAuthCredentials, UserInfo}; + +use crate::{ + db::models::{ChatRsUser, NewChatRsUser, UpdateChatRsUser}, + services::auth::{AuthResult, oauth::OAuthProvider}, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GoogleOAuthConfig { + client_id: String, + client_secret: String, +} + +pub struct GoogleProvider { + config: GoogleOAuthConfig, +} + +impl GoogleProvider { + pub fn new(config: &GoogleOAuthConfig) -> Self { + Self { + config: config.clone(), + } + } +} + +impl OAuthProvider for GoogleProvider { + fn get_inner_provider(&self) -> Box { + Box::new(simple_oauth::common::Google) + } + + fn get_credentials(&self) -> OAuthCredentials { + OAuthCredentials::new(&self.config.client_id, &self.config.client_secret) + } + + fn find_linked_user<'a>( + &self, + db: &'a mut crate::db::DbService, + user_data: &'a UserInfo, + ) -> BoxFuture<'a, AuthResult>> { + Box::pin(async move { + let user = db.users().find_by_google_id(&user_data.id).await?; + Ok(user) + }) + } + + fn is_user_linked(&self, user: &ChatRsUser) -> bool { + user.google_id.is_some() + } + + fn create_update_user<'a>(&self, user_data: &'a UserInfo) -> UpdateChatRsUser<'a> { + UpdateChatRsUser { + google_id: Some(&user_data.id), + ..Default::default() + } + } + + fn create_new_user<'a>(&self, user_data: &'a UserInfo) -> NewChatRsUser<'a> { + NewChatRsUser { + google_id: Some(&user_data.id), + name: user_data + .name + .as_deref() + .or(user_data.username.as_deref()) + .unwrap_or_default(), + avatar_url: user_data.avatar_url.as_deref(), + ..Default::default() + } + } +} diff --git a/server-new/src/services/auth/oauth/oidc.rs b/server-new/src/services/auth/oauth/oidc.rs new file mode 100644 index 0000000..065eb74 --- /dev/null +++ b/server-new/src/services/auth/oauth/oidc.rs @@ -0,0 +1,91 @@ +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use simple_oauth::{ + SimpleOAuthProvider, + types::{OAuthCredentials, OidcDiscovery}, +}; + +use crate::{ + db::models::{ChatRsUser, NewChatRsUser, UpdateChatRsUser}, + services::auth::AuthResult, +}; + +use super::OAuthProvider; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OidcConfig { + pub name: Option, + client_id: String, + client_secret: String, + auth_endpoint: String, + token_endpoint: String, + userinfo_endpoint: String, +} + +pub struct OidcProvider { + config: OidcConfig, +} + +impl OidcProvider { + pub fn new(config: &OidcConfig) -> Self { + Self { + config: config.clone(), + } + } +} + +impl OAuthProvider for OidcProvider { + fn get_inner_provider(&self) -> Box { + Box::new(simple_oauth::common::Oidc::from_config(OidcDiscovery { + authorization_endpoint: self.config.auth_endpoint.clone(), + token_endpoint: self.config.token_endpoint.clone(), + userinfo_endpoint: self.config.userinfo_endpoint.clone(), + ..Default::default() + })) + } + + fn get_credentials(&self) -> OAuthCredentials { + OAuthCredentials::new(&self.config.client_id, &self.config.client_secret) + } + + fn find_linked_user<'a>( + &self, + db: &'a mut crate::db::DbService, + user_info: &'a simple_oauth::types::UserInfo, + ) -> BoxFuture<'a, AuthResult>> { + Box::pin(async move { + let user = db.users().find_by_oidc_id(&user_info.id).await?; + Ok(user) + }) + } + + fn is_user_linked(&self, user: &ChatRsUser) -> bool { + user.oidc_id.is_some() + } + + fn create_update_user<'a>( + &self, + user_info: &'a simple_oauth::types::UserInfo, + ) -> crate::db::models::UpdateChatRsUser<'a> { + UpdateChatRsUser { + oidc_id: Some(&user_info.id), + ..Default::default() + } + } + + fn create_new_user<'a>( + &self, + user_info: &'a simple_oauth::types::UserInfo, + ) -> crate::db::models::NewChatRsUser<'a> { + NewChatRsUser { + google_id: Some(&user_info.id), + name: user_info + .name + .as_deref() + .or(user_info.username.as_deref()) + .unwrap_or_default(), + avatar_url: user_info.avatar_url.as_deref(), + ..Default::default() + } + } +} diff --git a/server-new/src/services/auth/proxy.rs b/server-new/src/services/auth/proxy.rs new file mode 100644 index 0000000..52335ef --- /dev/null +++ b/server-new/src/services/auth/proxy.rs @@ -0,0 +1,116 @@ +use axum::http::HeaderMap; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + db::{ + DbService, + models::{ChatRsUser, NewChatRsUser}, + }, + services::auth::error::{AuthError, AuthResult}, +}; + +/// SSO / forward auth proxy header configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyHeaderConfig { + /// Whether proxy header authentication is enabled + pub enabled: bool, + /// Header for unique, identifying username (default: `Remote-User`) + username_header: String, + /// Header for display name (default: `Remote-Name`) + name_header: String, + /// Header for space-delimited groups/roles of the user (default: `Remote-Groups`) + groups_header: String, + /// If set, only users in these groups will be allowed to access the app + user_groups: Option>, + /// URL to redirect to in order to log out of the remote service + logout_url: Option, +} + +impl Default for ProxyHeaderConfig { + fn default() -> Self { + Self { + enabled: false, + username_header: String::from("Remote-User"), + name_header: String::from("Remote-Name"), + groups_header: String::from("Remote-Groups"), + user_groups: None, + logout_url: None, + } + } +} + +pub struct ProxyService<'r> { + config: &'r ProxyHeaderConfig, +} + +pub struct ProxyUser { + username: String, + name: String, +} + +impl<'r> ProxyService<'r> { + pub fn new(config: &'r ProxyHeaderConfig) -> Self { + Self { config } + } + + pub fn extract_proxy_user(&self, headers: &HeaderMap) -> AuthResult> { + let Some(username) = headers.get(&self.config.username_header) else { + return Ok(None); + }; + let name = headers.get(&self.config.name_header).unwrap_or(username); + let groups = headers + .get(&self.config.groups_header) + .and_then(|groups| groups.to_str().ok()) + .unwrap_or_default(); + + if let Some(ref allowed_groups) = self.config.user_groups + && !is_proxy_user_allowed(groups, allowed_groups) + { + return Err(AuthError::Unauthorized("proxy user not in allowed group")); + } + + Ok(Some(ProxyUser { + username: username.to_str().unwrap_or_default().to_owned(), + name: name.to_str().unwrap_or_default().to_owned(), + })) + } + + pub async fn find_proxy_user( + &self, + db: &mut DbService, + proxy_user: &ProxyUser, + ) -> AuthResult> { + let user_id = db + .users() + .find_by_sso_username(&proxy_user.username) + .await?; + Ok(user_id) + } + + pub async fn create_proxy_user( + &self, + db: &mut DbService, + proxy_user: &ProxyUser, + ) -> AuthResult { + let new_user = db + .users() + .create(NewChatRsUser { + sso_username: Some(&proxy_user.username), + name: &proxy_user.name, + ..Default::default() + }) + .await?; + Ok(new_user) + } +} + +fn is_proxy_user_allowed(user_groups: &str, allowed_groups: &[String]) -> bool { + for user_group in user_groups.split(' ') { + if allowed_groups.iter().any(|g| g == user_group) { + return true; + } + } + + false +} diff --git a/server-new/src/services/auth/session.rs b/server-new/src/services/auth/session.rs new file mode 100644 index 0000000..5f469f3 --- /dev/null +++ b/server-new/src/services/auth/session.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; + +use tower_sessions::{ + Expiry, Session, + cookie::time::Duration, + session_store::{Error as StoreError, Result as StoreResult}, +}; +use uuid::Uuid; + +use crate::{ + db::{DbPool, DbService}, + extractors::SessionMeta, + services::auth::AuthResult, +}; + +/// The field used to store the user ID in the session. +const USER_ID_FIELD: &str = "user_id"; +/// The field used to store the user session metadata. +const META_FIELD: &str = "meta"; + +/// Authentication-specific operations on a tower session. +pub struct AuthSessionService { + session_length: i64, +} + +impl AuthSessionService { + pub fn new(session_length: i64) -> Self { + Self { session_length } + } + + /// Initialize a new logged-in session for the given user. + pub async fn login( + &self, + session: &Session, + meta: &SessionMeta, + user_id: &Uuid, + ) -> AuthResult<()> { + session.cycle_id().await?; // ensures that the user id is saved to the database + session.insert(USER_ID_FIELD, user_id).await?; + session.insert(META_FIELD, meta).await?; + session.set_expiry(Some(Expiry::OnInactivity(Duration::seconds( + self.session_length, + )))); + + Ok(()) + } + + /// Extract the current user ID if this is an active user session. + pub async fn active_user_id(&self, session: &Session) -> AuthResult> { + let user_id = session.get::(USER_ID_FIELD).await?; + Ok(user_id) + } + + /// Extract the user id from the raw session hashmap + pub(super) fn user_id_from_record_data( + data: &HashMap, + ) -> StoreResult> { + data.get(USER_ID_FIELD) + .and_then(|val| val.as_str().map(Uuid::try_parse)) + .transpose() + .map_err(|_| StoreError::Decode("invalid user id field".into())) + } + + /// Logout the user, deleting the current session. + pub async fn logout(&self, session: &Session) -> AuthResult<()> { + Ok(session.flush().await?) + } + + // Cleanup expired sessions + #[tracing::instrument(skip(db_pool), level = "debug")] + pub async fn session_cleanup(db_pool: &DbPool) -> AuthResult { + let mut db = DbService::from_pool(db_pool).await?; + Ok(db.auth_sessions().delete_expired().await?) + } +} diff --git a/server-new/src/services/auth/session_store.rs b/server-new/src/services/auth/session_store.rs new file mode 100644 index 0000000..52afb61 --- /dev/null +++ b/server-new/src/services/auth/session_store.rs @@ -0,0 +1,115 @@ +use async_trait::async_trait; +use tower_sessions::{ + SessionStore, + cookie::time::OffsetDateTime, + session::{Id, Record}, + session_store::{Error, Result}, +}; +use uuid::Uuid; + +use crate::{ + db::{DbPool, DbService, UtcDateTime}, + services::auth::session::AuthSessionService, +}; + +#[derive(Clone)] +pub struct SessionDbStore { + db: DbPool, +} + +impl SessionDbStore { + pub fn new(db: DbPool) -> Self { + Self { db } + } + + fn get_session_uuid(id: &Id) -> Uuid { + Uuid::from_bytes(id.0.to_be_bytes()) + } + + fn convert_expiry(time: OffsetDateTime) -> Result { + UtcDateTime::from_timestamp_secs(time.unix_timestamp()) + .ok_or_else(|| Error::Backend(format!("Invalid expiry: {time}"))) + } + + async fn get_db(&self) -> Result { + DbService::from_pool(&self.db) + .await + .map_err(|err| Error::Backend(err.to_string())) + } +} + +impl std::fmt::Debug for SessionDbStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionDbStore").finish() + } +} + +#[async_trait] +impl SessionStore for SessionDbStore { + /// Creates a new session in the store with the provided session record. + async fn create(&self, record: &mut Record) -> Result<()> { + let session_id = Self::get_session_uuid(&record.id); + let user_id = AuthSessionService::user_id_from_record_data(&record.data)?; + let expires_at = Self::convert_expiry(record.expiry_date)?; + + let mut db = self.get_db().await?; + db.auth_sessions() + .create(&session_id, user_id.as_ref(), &record.data, expires_at) + .await + .map_err(|err| Error::Backend(err.to_string()))?; + + Ok(()) + } + + /// Saves the provided session record to the store. + /// + /// This method is intended for updating the state of an existing session. + async fn save(&self, record: &Record) -> Result<()> { + let session_id = Self::get_session_uuid(&record.id); + let expires_at = Self::convert_expiry(record.expiry_date)?; + + let mut db = self.get_db().await?; + db.auth_sessions() + .update(&session_id, &record.data, expires_at) + .await + .map_err(|err| Error::Backend(err.to_string()))?; + + Ok(()) + } + + /// Loads an existing session record from the store using the provided ID. + /// + /// If a session with the given ID exists, it is returned. If the session + /// does not exist or has been invalidated (e.g., expired), `None` is + /// returned. + async fn load(&self, session_id: &Id) -> Result> { + let session_id = Self::get_session_uuid(session_id); + let mut db = self.get_db().await?; + + match db.auth_sessions().find_active_by_id(&session_id).await { + Ok(Some(session)) => Ok(Some(Record { + id: Id(i128::from_be_bytes(session.id.into_bytes())), + data: session.data.0, + expiry_date: OffsetDateTime::from_unix_timestamp(session.expires_at.timestamp()) + .map_err(|err| Error::Backend(format!("Invalid expiry: {err}")))?, + })), + Ok(None) => Ok(None), + Err(err) => Err(Error::Backend(err.to_string())), + } + } + + /// Deletes a session record from the store using the provided ID. + /// + /// If the session exists, it is removed from the store. + async fn delete(&self, session_id: &Id) -> Result<()> { + let session_id = Self::get_session_uuid(session_id); + let mut db = self.get_db().await?; + + db.auth_sessions() + .delete_by_id(&session_id) + .await + .map_err(|err| Error::Backend(err.to_string()))?; + + Ok(()) + } +} diff --git a/server-new/src/services/chat/error.rs b/server-new/src/services/chat/error.rs new file mode 100644 index 0000000..b4367e8 --- /dev/null +++ b/server-new/src/services/chat/error.rs @@ -0,0 +1,38 @@ +use crate::error::AppError; + +/// Chat service errors +#[derive(Debug, thiserror::Error)] +pub enum ChatError { + #[error("invalid message history")] + Messages, + #[error("session not found")] + SessionNotFound, + #[error("stream not found")] + StreamNotFound, + #[error("no assistant response")] + NoAssistantResponse, + #[error("already streaming a response")] + AlreadyStreaming, + #[error(transparent)] + Request(#[from] crate::llm::error::LlmRequestError), + #[error(transparent)] + Streaming(#[from] crate::services::stream::error::StreamingError), + #[error("database error: {0}")] + Database(#[from] diesel::result::Error), + #[error("database pool error: {0}")] + DatabasePool(#[from] crate::db::DbPoolError), +} + +impl From for AppError { + fn from(value: ChatError) -> Self { + match value { + ChatError::SessionNotFound => Self::not_found("chat session not found"), + ChatError::StreamNotFound => Self::not_found("stream not found for this session"), + ChatError::Messages => Self::bad_request("invalid messages"), + ChatError::NoAssistantResponse => Self::bad_request("no assistant response found"), + ChatError::AlreadyStreaming => Self::bad_request("already streaming this chat session"), + ChatError::Request(err) => Self::bad_request(err.to_string()), + err => Self::internal(err.into()), + } + } +} diff --git a/server-new/src/services/chat/messages.rs b/server-new/src/services/chat/messages.rs new file mode 100644 index 0000000..bac8902 --- /dev/null +++ b/server-new/src/services/chat/messages.rs @@ -0,0 +1,76 @@ +use crate::{ + db::models::{ChatRsMessage, ChatRsMessageRole}, + llm::types::{LlmAssistantMessage, LlmMessage, LlmUserMessage}, + services::chat::error::ChatError, +}; + +/// Extract any attached files, then convert the database messages to the generic format +/// for sending to LLM providers +pub fn build_llm_messages( + messages: Vec, + // user_id: &Uuid, + // session_id: &Uuid, + // db: &mut DbConnection, + // storage: &LocalStorage, +) -> Result, ChatError> { + // // Get content of any attached files in the messages + // let mut file_map: HashMap = HashMap::new(); + // let file_ids: Vec = messages.iter().fold(Vec::new(), |mut acc, message| { + // if let Some(file_ids) = message.meta.user.as_ref().and_then(|u| u.files.as_ref()) { + // acc.extend(file_ids); + // } + // acc + // }); + // for file_id in file_ids { + // let file = FileDbService::new(db) + // .find_session_file(user_id, session_id, &file_id) + // .await?; + // let (file_type, content) = file.read_to_string(Some(session_id), storage).await?; + // file_map.insert( + // file_id, + // LlmFileInput { + // name: file.path, + // content_type: file.content_type, + // file_type, + // content, + // }, + // ); + // } + + // Convert the messages + let llm_messages = messages + .into_iter() + .map(|message| match message.role { + ChatRsMessageRole::User => { + // let files = message.meta.user.and_then(|u| u.files).map(|file_ids| { + // file_ids + // .iter() + // .filter_map(|id| file_map.remove(id)) + // .collect() + // }); + Ok(LlmMessage::User(LlmUserMessage { + text: message.content, + files: None, + })) + } + ChatRsMessageRole::Assistant => Ok(LlmMessage::Assistant(LlmAssistantMessage { + text: message.content, + // tool_calls: message.meta.assistant.and_then(|a| a.tool_calls), + })), + ChatRsMessageRole::System => Ok(LlmMessage::System(message.content)), + ChatRsMessageRole::Tool => { + // if let Some(tool_call) = message.meta.tool_call { + // Ok(LlmMessage::Tool(LlmToolResult { + // tool_call_id: tool_call.id, + // tool_name: tool_call.tool_name, + // content: message.content, + // })) + // } else { + Err(ChatError::Messages) + // } + } + }) + .collect::, ChatError>>()?; + + Ok(llm_messages) +} diff --git a/server-new/src/services/chat/mod.rs b/server-new/src/services/chat/mod.rs new file mode 100644 index 0000000..3d370bc --- /dev/null +++ b/server-new/src/services/chat/mod.rs @@ -0,0 +1,364 @@ +use std::{sync::Arc, time::Instant}; + +use tinistream_client::types::{StreamAccessResponse, StreamStatus}; +use uuid::Uuid; + +use crate::{ + db::{ + DbPool, DbService, + models::{ + AssistantMeta, ChatRsLogKind, ChatRsLogStatus, ChatRsMessage, ChatRsMessageMeta, + ChatRsMessageRole, NewChatRsMessage, UpdateChatRsLog, UserMeta, + }, + repositories::{LlmLogComplete, LlmLogCreate}, + }, + llm::{ + interface::{LlmProvider, LlmResponseMeta}, + types::{LlmChatOptions, LlmChatRequest, LlmMessage, LlmUserMessage}, + }, + services::{ + chat::error::ChatError, + stream::{LlmStreamOutput, StreamingService, tinistream::TinistreamClient}, + }, +}; + +mod error; +mod messages; +mod titles; + +pub const DEFAULT_SESSION_TITLE: &str = "New Chat"; + +pub struct ChatService<'r> { + db_pool: &'r DbPool, + tinistream: &'r TinistreamClient, +} + +#[derive(Debug)] +struct ChatStreamParams { + user_id: Uuid, + session_id: Uuid, + provider_id: i32, + chat_options: LlmChatOptions, + replace_message_id: Option, +} + +impl<'r> ChatService<'r> { + pub fn new(db_pool: &'r DbPool, tinistream: &'r TinistreamClient) -> Self { + Self { + db_pool, + tinistream, + } + } + + /// Connect to an ongoing stream + pub async fn connect_stream( + &self, + user_id: &Uuid, + session_id: &Uuid, + ) -> Result { + let stream_key = StreamingService::chat_stream_key(user_id, session_id); + let streams = StreamingService::new(self.tinistream); + if !streams.exists_stream(&stream_key).await? { + return Err(ChatError::StreamNotFound); + } + + Ok(streams.access_stream(&stream_key).await?) + } + + /// Cancel an ongoing stream + pub async fn cancel_stream( + &self, + user_id: &Uuid, + session_id: &Uuid, + ) -> Result { + let stream_key = StreamingService::chat_stream_key(user_id, session_id); + let streams = StreamingService::new(self.tinistream); + if !streams.exists_stream(&stream_key).await? { + return Err(ChatError::StreamNotFound); + } + + Ok(streams.cancel_stream(&stream_key).await?) + } + + /// Get the currently streaming session IDs for the given user + pub async fn active_stream_sessions(&self, user_id: &Uuid) -> Result, ChatError> { + let prefix = StreamingService::chat_stream_prefix(user_id); + let session_ids = StreamingService::new(self.tinistream) + .active_streams(&prefix) + .await? + .iter() + .filter_map(|stream| StreamingService::session_id_from_stream_key(&stream.key, &prefix)) + .collect(); + + Ok(session_ids) + } + + /// Send a single prompt to the LLM provider and stream the response + pub async fn prompt( + &self, + db: &mut DbService, + user_id: Uuid, + provider_id: i32, + provider: Arc, + prompt: LlmUserMessage, + options: LlmChatOptions, + ) -> Result { + let create_log = LlmLogCreate { + kind: ChatRsLogKind::Prompt, + user_id, + provider_id, + llm_options: Some(&options), + ..Default::default() + }; + let log = db.logs().create(create_log).await?; + + let start_time = Instant::now(); + let request = LlmChatRequest { + messages: &[LlmMessage::User(prompt)], + options: &options, + }; + let (response_stream, response_meta) = match provider.stream_chat(request).await { + Ok(response) => response, + Err(err) => { + let complete_log = LlmLogComplete { + status: ChatRsLogStatus::Error, + request_id: err.req_id(), + errors: Some(vec![err.to_string()]), + ..Default::default() + }; + db.logs().complete(log, complete_log).await?; + return Err(ChatError::Request(err)); + } + }; + + // Spawn thread to process LLM streaming response + let stream_key = StreamingService::prompt_key(&user_id); + let (stream_access, ws_writer, ws_reader) = StreamingService::new(self.tinistream) + .create_stream(&stream_key) + .await?; + let db_pool = self.db_pool.to_owned(); + let tinistream_client = self.tinistream.to_owned(); + tokio::spawn(async move { + let output = + StreamingService::process_stream(response_stream, start_time, ws_writer, ws_reader) + .await; + if let Ok(mut db) = DbService::from_pool(&db_pool).await { + let complete_log = LlmLogComplete { + status: output.status(), + request_id: response_meta.request_id.as_deref(), + usage: output.usage.as_ref(), + errors: output.errors, + first_token_in: output.first_token_in, + ..Default::default() + }; + let _ = db.logs().complete(log, complete_log).await; + } + let _ = StreamingService::new(&tinistream_client) + .end_stream(&stream_key) + .await; + }); + + // Return the URL and token for the user to access the client stream + Ok(stream_access) + } + + /// Stream response to a user message in a session + pub async fn stream_user_chat( + &self, + db: &mut DbService, + user_id: Uuid, + session_id: Uuid, + provider_id: i32, + provider: Arc, + user_message: Option, + chat_options: LlmChatOptions, + ) -> Result { + let mut chats = db.chats(); + let chat_session = chats + .find_session(&user_id, &session_id) + .await? + .ok_or(ChatError::SessionNotFound)?; + let mut messages = chats.list_messages(&session_id).await?; + + if let Some(user_message) = user_message { + if messages.is_empty() && chat_session.title == DEFAULT_SESSION_TITLE { + titles::generate_title( + user_id, + session_id, + provider_id, + &provider, + &user_message.text, + &chat_options.model, + self.db_pool, + ); + } + let new_message = chats + .save_message(NewChatRsMessage { + content: &user_message.text, + session_id: &session_id, + role: ChatRsMessageRole::User, + meta: ChatRsMessageMeta::new_user(UserMeta::default()), + }) + .await?; + messages.push(new_message); + } + + self.start_assistant_stream( + db, + provider, + messages, + ChatStreamParams { + user_id, + session_id, + provider_id, + chat_options, + replace_message_id: None, + }, + ) + .await + } + + /// Regenerate the last assistant response in a chat session + pub async fn regenerate_response( + &self, + db: &mut DbService, + user_id: Uuid, + session_id: Uuid, + provider_id: i32, + provider: Arc, + chat_options: LlmChatOptions, + ) -> Result { + let mut chats = db.chats(); + let chat_session = chats + .find_session(&user_id, &session_id) + .await? + .ok_or(ChatError::SessionNotFound)?; + let mut messages = chats.list_messages(&chat_session.id).await?; + + let last_message = messages.pop(); + if last_message.as_ref().is_none_or(|m| !m.role.is_assistant()) { + return Err(ChatError::NoAssistantResponse); + } + + self.start_assistant_stream( + db, + provider, + messages, + ChatStreamParams { + user_id, + session_id, + provider_id, + chat_options, + replace_message_id: last_message.map(|m| m.id), + }, + ) + .await + } + + /// Start the LLM response stream and return the access URL & token + async fn start_assistant_stream( + &self, + db: &mut DbService, + provider: Arc, + messages: Vec, + params: ChatStreamParams, + ) -> Result { + let streams = StreamingService::new(self.tinistream); + let stream_key = StreamingService::chat_stream_key(¶ms.user_id, ¶ms.session_id); + if streams.exists_stream(&stream_key).await? { + return Err(ChatError::AlreadyStreaming); + } + + let create_log = LlmLogCreate { + kind: ChatRsLogKind::Chat, + user_id: params.user_id, + provider_id: params.provider_id, + llm_options: Some(¶ms.chat_options), + session_id: Some(¶ms.session_id), + }; + let log = db.logs().create(create_log).await?; + + let start_time = Instant::now(); + let request = LlmChatRequest { + messages: &messages::build_llm_messages(messages)?, + options: ¶ms.chat_options, + }; + let (response_stream, meta) = match provider.stream_chat(request).await { + Ok(response) => response, + Err(err) => { + let complete_log = LlmLogComplete { + status: ChatRsLogStatus::Error, + request_id: err.req_id(), + errors: Some(vec![err.to_string()]), + ..Default::default() + }; + db.logs().complete(log, complete_log).await?; + return Err(ChatError::Request(err)); + } + }; + + // Spawn thread to process and save LLM streaming response + let (stream_access, ws_writer, ws_reader) = streams.create_stream(&stream_key).await?; + let db_pool = self.db_pool.to_owned(); + let tinistream_client = self.tinistream.to_owned(); + tokio::spawn(async move { + let output = + StreamingService::process_stream(response_stream, start_time, ws_writer, ws_reader) + .await; + let stream_cancelled = output.cancelled; + if let Err(err) = Self::persist_response(output, params, log, meta, db_pool).await { + tracing::error!("Failed to save assistant response: {err}"); + } + + if !stream_cancelled { + let _ = StreamingService::new(&tinistream_client) + .end_stream(&stream_key) + .await; + } + }); + + // Return the URL and token for the user to access the client stream + Ok(stream_access) + } + + /// Save response message and metadata to database + async fn persist_response( + output: LlmStreamOutput, + params: ChatStreamParams, + log: UpdateChatRsLog, + meta: LlmResponseMeta, + db_pool: DbPool, + ) -> Result { + let completed_at = chrono::Utc::now(); + let mut db = DbService::from_pool(&db_pool).await?; + + let assistant_meta = AssistantMeta::default(); + let new_message = db + .chats() + .save_message(NewChatRsMessage { + content: output.text.as_deref().unwrap_or_default(), + meta: ChatRsMessageMeta::new_assistant(assistant_meta), + role: ChatRsMessageRole::Assistant, + session_id: ¶ms.session_id, + }) + .await?; + if let Some(message_id) = params.replace_message_id { + db.chats() + .delete_message(¶ms.session_id, &message_id) + .await?; + } + + let complete_log = LlmLogComplete { + status: output.status(), + message_id: Some(new_message.id), + request_id: meta.request_id.as_deref(), + usage: output.usage.as_ref(), + errors: output.errors, + first_token_in: output.first_token_in, + completed_at: Some(completed_at), + }; + db.logs().complete(log, complete_log).await?; + + Ok(new_message) + } +} diff --git a/server-new/src/services/chat/titles.rs b/server-new/src/services/chat/titles.rs new file mode 100644 index 0000000..ff2e507 --- /dev/null +++ b/server-new/src/services/chat/titles.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::{ + db::{ + DbPool, DbService, + models::{ChatRsLogKind, ChatRsLogStatus, UpdateChatRsSession}, + repositories::{LlmLogComplete, LlmLogCreate}, + }, + llm::{ + interface::{LlmProvider, LlmResponse}, + types::{LlmChatOptions, LlmPrompt}, + }, + services::chat::error::ChatError, +}; + +/// Spawn a task to generate a title for the chat session +pub fn generate_title( + user_id: Uuid, + sess_id: Uuid, + provider_id: i32, + provider: &Arc, + first_message: &str, + model: &str, + pool: &DbPool, +) { + let msg = first_message.to_owned(); + let provider = Arc::clone(provider); + let model = model.to_owned(); + let pool = pool.to_owned(); + + tokio::spawn(async move { + if let Err(err) = generate(user_id, sess_id, provider_id, provider, msg, model, pool).await + { + tracing::warn!("Error while generating session title: {err}"); + } + }); +} + +const TITLE_PROMPT: &str = "This is the first message sent by a human in a chat session with an AI chatbot. \ + Please generate a short title for the chat session (3-7 words) in plain text, with no quotes or prefixes."; +const TITLE_PROMPT_TEMPERATURE: f32 = 0.7; +const TITLE_PROMPT_MAX_TOKENS: u32 = 20; + +async fn generate( + user_id: Uuid, + session_id: Uuid, + provider_id: i32, + provider: Arc, + user_message: String, + model: String, + db_pool: DbPool, +) -> Result<(), ChatError> { + let prompt_options = LlmChatOptions { + model, + temperature: Some(TITLE_PROMPT_TEMPERATURE), + max_tokens: Some(TITLE_PROMPT_MAX_TOKENS), + }; + + let mut db = DbService::from_pool(&db_pool).await?; + let create_log = LlmLogCreate { + kind: ChatRsLogKind::Title, + user_id, + provider_id, + session_id: Some(&session_id), + llm_options: Some(&prompt_options), + }; + let log = db.logs().create(create_log).await?; + drop(db); + + let prompt = LlmPrompt { + text: &format!("{TITLE_PROMPT}\n\n\"{user_message}\""), + options: &prompt_options, + }; + match provider.prompt(prompt).await { + Ok(LlmResponse { text, usage, meta }) => { + let completed_at = chrono::Utc::now(); + + let mut db = DbService::from_pool(&db_pool).await?; + db.chats() + .update_session( + &user_id, + &session_id, + UpdateChatRsSession { + title: Some(text.trim()), + ..Default::default() + }, + ) + .await?; + + let complete_log = LlmLogComplete { + usage: Some(&usage), + request_id: meta.request_id.as_deref(), + completed_at: Some(completed_at), + ..Default::default() + }; + db.logs().complete(log, complete_log).await?; + } + Err(err) => { + let mut db = DbService::from_pool(&db_pool).await?; + let complete_log = LlmLogComplete { + status: ChatRsLogStatus::Error, + request_id: err.req_id(), + errors: Some(vec![err.to_string()]), + ..Default::default() + }; + db.logs().complete(log, complete_log).await?; + } + } + + Ok(()) +} diff --git a/server-new/src/services/mod.rs b/server-new/src/services/mod.rs new file mode 100644 index 0000000..b68e8b8 --- /dev/null +++ b/server-new/src/services/mod.rs @@ -0,0 +1,6 @@ +pub mod auth; +pub mod chat; +pub mod model; +pub mod provider; +pub mod storage; +pub mod stream; diff --git a/server-new/src/services/model/error.rs b/server-new/src/services/model/error.rs new file mode 100644 index 0000000..b51ecdd --- /dev/null +++ b/server-new/src/services/model/error.rs @@ -0,0 +1,19 @@ +use crate::error::AppError; + +#[derive(Debug, thiserror::Error)] +pub enum ModelError { + #[error("request error: {0}")] + Request(#[from] reqwest::Error), + #[error("models.dev provider not found: {0}")] + ModelsDevProviderNotFound(&'static str), + #[error("Redis error: {0}")] + Redis(#[from] fred::prelude::Error), + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} + +impl From for AppError { + fn from(error: ModelError) -> Self { + Self::internal(error.into()) + } +} diff --git a/server-new/src/services/model/mod.rs b/server-new/src/services/model/mod.rs new file mode 100644 index 0000000..e15a3c1 --- /dev/null +++ b/server-new/src/services/model/mod.rs @@ -0,0 +1,126 @@ +use std::{collections::HashMap, str::FromStr}; + +use fred::prelude::{HashesInterface, KeysInterface}; +use serde::Deserialize; +use strum::{AsRefStr, EnumIter, IntoEnumIterator, IntoStaticStr}; + +use crate::{ + db::models::{ChatRsProvider, ChatRsProviderType, OpenAISubtype}, + services::model::{error::ModelError, types::LlmModel}, +}; + +pub mod error; +mod providers; +pub mod types; + +/// Service for fetching/listing available LLM models +pub struct ModelService<'r> { + redis: &'r fred::prelude::Pool, + http_client: &'r reqwest::Client, +} + +impl<'r> ModelService<'r> { + pub fn new(redis: &'r fred::prelude::Pool, http_client: &'r reqwest::Client) -> Self { + Self { redis, http_client } + } + + pub async fn list_models( + &self, + provider: &ChatRsProvider, + provider_type: &ChatRsProviderType, + ) -> Result, ModelError> { + match provider_type { + ChatRsProviderType::OpenAI => { + let subtype = provider.openai_subtype.as_deref().unwrap_or_default(); + let md_provider = match OpenAISubtype::from_str(subtype).unwrap_or_default() { + OpenAISubtype::OpenAI => ModelsDevProvider::OpenAI, + OpenAISubtype::OpenRouter => ModelsDevProvider::OpenRouter, + }; + self.fetch_models_dev(md_provider).await + } + ChatRsProviderType::Anthropic => { + self.fetch_models_dev(ModelsDevProvider::Anthropic).await + } + ChatRsProviderType::Ollama => { + providers::ollama_models(self.http_client, provider.base_url.as_deref()).await + } + ChatRsProviderType::Lorem => Ok(vec![LlmModel { + id: String::from("lorem"), + name: String::from("lorem"), + ..Default::default() + }]), + } + } + + /// Fetch detailed model list from `models.dev` with caching + async fn fetch_models_dev( + &self, + md_provider: ModelsDevProvider, + ) -> Result, ModelError> { + const MODELS_DEV_URL: &str = "https://models.dev/api.json"; + const CACHE_KEY: &str = "rs-chat:models"; + const CACHE_TTL: i64 = 86400; // 1 day in seconds + + if let Some(models) = self + .redis + .hget::, _, _>(CACHE_KEY, md_provider.as_ref()) + .await? + .and_then(|models| serde_json::from_str(&models).ok()) + { + Ok(models) + } else { + let mut res: ModelsDevResponse = self + .http_client + .get(MODELS_DEV_URL) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let mut models: Option> = None; + let mut cache: HashMap = HashMap::new(); + + for provider in ModelsDevProvider::iter() { + let provider_models: Vec = res + .remove(provider.as_ref()) + .ok_or_else(|| ModelError::ModelsDevProviderNotFound(provider.into()))? + .models + .into_values() + .collect(); + let provider_models_str = serde_json::to_string(&provider_models)?; + cache.insert(provider.as_ref().to_owned(), provider_models_str); + + if md_provider == provider { + models = Some(provider_models); + } + } + + let pipeline = self.redis.next().pipeline(); + let _: () = pipeline.hset(CACHE_KEY, cache).await?; + let _: () = pipeline.expire(CACHE_KEY, CACHE_TTL, None).await?; + let _: () = pipeline.all().await?; + + Ok(models.unwrap_or_default()) + } + } +} + +/// Represents a provider from `models.dev` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, IntoStaticStr, AsRefStr, EnumIter)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +enum ModelsDevProvider { + OpenAI, + OpenRouter, + Anthropic, +} + +/// Main response from `models.dev` - map of providers +type ModelsDevResponse = HashMap; + +/// Provider data from `models.dev` +#[derive(Debug, Deserialize)] +struct ModelsDevProviderData { + models: HashMap, +} diff --git a/server-new/src/services/model/providers.rs b/server-new/src/services/model/providers.rs new file mode 100644 index 0000000..f1b7243 --- /dev/null +++ b/server-new/src/services/model/providers.rs @@ -0,0 +1,38 @@ +use super::{ + error::ModelError, + types::{LlmModel, OllamaModelsResponse}, +}; + +pub(super) async fn ollama_models( + client: &reqwest::Client, + base_url: Option<&str>, +) -> Result, ModelError> { + const DEFAULT_BASE_URL: &str = "http://localhost:11434"; + const MODELS_API_PATH: &str = "/api/tags"; + + let models_url = format!("{}{MODELS_API_PATH}", base_url.unwrap_or(DEFAULT_BASE_URL)); + let response: OllamaModelsResponse = client + .get(models_url) + .send() + .await? + .error_for_status()? + .json() + .await?; + let models = response + .models + .into_iter() + .map(|model| LlmModel { + id: model.model, + name: model.name, + temperature: Some(true), + modified_at: Some(model.modified_at), + format: Some(model.details.format), + family: Some(model.details.family), + tool_call: Some(model.capabilities.iter().any(|c| c.is_tools())), + reasoning: Some(model.capabilities.iter().any(|c| c.is_thinking())), + ..Default::default() + }) + .collect(); + + Ok(models) +} diff --git a/server-new/src/services/model/types.rs b/server-new/src/services/model/types.rs new file mode 100644 index 0000000..04d01fc --- /dev/null +++ b/server-new/src/services/model/types.rs @@ -0,0 +1,83 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// A model supported by the LLM provider +#[skip_serializing_none] +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct LlmModel { + /// The model ID to use in a chat / prompt request + pub id: String, + pub name: String, + pub attachment: Option, + pub reasoning: Option, + pub temperature: Option, + pub tool_call: Option, + pub release_date: Option, + pub knowledge: Option, + pub modalities: Option, + // // Ollama fields + pub modified_at: Option, + pub format: Option, + pub family: Option, +} + +#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] +pub struct Modalities { + input: Vec, + output: Vec, +} + +#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ModalityType { + Text, + Image, + Audio, + Video, + Pdf, +} + +/// Ollama models list response +#[derive(Debug, Deserialize)] +pub struct OllamaModelsResponse { + pub models: Vec, +} + +/// Ollama model information +#[derive(Debug, Deserialize)] +pub struct OllamaModelInfo { + pub name: String, + pub model: String, + pub modified_at: String, + // pub size: u64, + // pub digest: String, + pub details: OllamaModelDetails, + #[serde(default)] + pub capabilities: Vec, +} + +/// Ollama model details +#[derive(Debug, Deserialize)] +pub struct OllamaModelDetails { + // #[serde(default)] + // pub parent_model: String, + pub format: String, + pub family: String, + // #[serde(default)] + // pub families: Vec, + // pub parameter_size: String, + // #[serde(default)] + // pub quantization_level: Option, +} + +#[derive(Debug, Deserialize, strum::EnumIs)] +#[serde(rename_all = "lowercase")] +pub enum OllamaCapabilities { + Completion, + Tools, + Vision, + Thinking, + #[serde(other)] + Unknown, +} diff --git a/server-new/src/services/provider/error.rs b/server-new/src/services/provider/error.rs new file mode 100644 index 0000000..fc4c271 --- /dev/null +++ b/server-new/src/services/provider/error.rs @@ -0,0 +1,25 @@ +use crate::{error::AppError, services::auth::encryption::EncryptorError}; + +#[derive(Debug, thiserror::Error)] +pub enum ProviderError { + #[error("provider not found")] + NotFound, + #[error("missing API key")] + MissingApiKey, + #[error("invalid provider type: {0}")] + InvalidProviderType(#[from] strum::ParseError), + #[error("error reading/writing API keys: {0}")] + Encryption(#[from] EncryptorError), + #[error("database error: {0}")] + Database(#[from] diesel::result::Error), +} + +impl From for AppError { + fn from(value: ProviderError) -> Self { + match value { + ProviderError::NotFound => Self::not_found("provider not found"), + ProviderError::MissingApiKey => Self::bad_request("missing API key for this provider"), + error => Self::internal(error.into()), + } + } +} diff --git a/server-new/src/services/provider/mod.rs b/server-new/src/services/provider/mod.rs new file mode 100644 index 0000000..5e42810 --- /dev/null +++ b/server-new/src/services/provider/mod.rs @@ -0,0 +1,200 @@ +use std::{str::FromStr, sync::Arc}; + +use uuid::Uuid; + +use crate::{ + db::{ + DbService, + models::{ + ChatRsProvider, ChatRsProviderType, ChatRsSecret, NewChatRsProvider, NewChatRsSecret, + OpenAISubtype, UpdateChatRsProvider, UpdateChatRsSecret, + }, + }, + llm::{interface::LlmProvider, providers::*}, + services::{ + auth::encryption::Encryptor, + provider::{ + error::ProviderError, + types::{ProviderCreateInput, ProviderUpdateInput}, + }, + }, +}; + +mod error; +pub mod types; + +pub struct ProviderService<'r> { + encryptor: &'r Encryptor, + http_client: &'r reqwest::Client, +} + +impl<'r> ProviderService<'r> { + pub fn new(encryptor: &'r Encryptor, http_client: &'r reqwest::Client) -> Self { + Self { + encryptor, + http_client, + } + } + + pub async fn build_llm_provider( + &self, + db: &mut DbService, + user_id: &Uuid, + provider_id: i32, + ) -> Result, ProviderError> { + let (provider, provider_type, api_key_secret) = + self.get_provider(db, user_id, provider_id).await?; + let api_key = api_key_secret + .map(|secret| { + self.encryptor + .decrypt_string(&secret.ciphertext, &secret.nonce) + }) + .transpose()?; + let llm_provider: Arc = match provider_type { + ChatRsProviderType::Lorem => Arc::new(LoremProvider::new()), + ChatRsProviderType::OpenAI => Arc::new(OpenAIProvider::new( + self.http_client, + OpenAIProviderConfig::new( + provider + .openai_subtype + .and_then(|s| OpenAISubtype::from_str(&s).ok()) + .unwrap_or_default(), + api_key.ok_or(ProviderError::MissingApiKey)?, + provider.base_url, + ), + )), + ChatRsProviderType::Anthropic => Arc::new(AnthropicProvider::new( + self.http_client, + api_key.ok_or(ProviderError::MissingApiKey)?, + )), + ChatRsProviderType::Ollama => Arc::new(OllamaProvider::new( + self.http_client, + provider + .base_url + .as_deref() + .unwrap_or("http://localhost:11434"), + )), + }; + + Ok(llm_provider) + } + + pub async fn get_provider( + &self, + db: &mut DbService, + user_id: &Uuid, + provider_id: i32, + ) -> Result<(ChatRsProvider, ChatRsProviderType, Option), ProviderError> { + let (provider, api_key_secret) = db + .providers() + .find_by_id(user_id, provider_id) + .await? + .ok_or(ProviderError::NotFound)?; + let provider_type = ChatRsProviderType::from_str(&provider.provider_type)?; + + Ok((provider, provider_type, api_key_secret)) + } + + pub async fn create_provider( + &self, + db: &mut DbService, + user_id: &Uuid, + input: &ProviderCreateInput, + ) -> Result { + let mut api_key_id: Option = None; + if let Some(plaintext_key) = input.api_key.as_deref() { + let (ciphertext, nonce) = self.encryptor.encrypt_string(plaintext_key)?; + let secret_id = db + .secrets() + .create(NewChatRsSecret { + user_id, + name: &format!("{} API Key", input.name), + ciphertext: &ciphertext, + nonce: &nonce, + }) + .await?; + api_key_id = Some(secret_id); + } + let provider = db + .providers() + .create(NewChatRsProvider { + name: &input.name, + user_id, + provider_type: input.r#type.into(), + openai_subtype: input.openai_type.map(|t| t.into()), + base_url: input.base_url.as_deref(), + default_model: &input.default_model, + api_key_id, + }) + .await?; + + Ok(provider) + } + + pub async fn update_provider( + &self, + db: &mut DbService, + user_id: &Uuid, + provider_id: i32, + input: &ProviderUpdateInput, + ) -> Result { + let (provider, _, secret) = self.get_provider(db, user_id, provider_id).await?; + + let mut secret_id: Option = None; + if let Some(new_api_key) = input.api_key.as_deref() { + let (ciphertext, nonce) = self.encryptor.encrypt_string(new_api_key)?; + secret_id = match secret { + Some(existing_secret) => { + let update_secret = UpdateChatRsSecret { + ciphertext: Some(&ciphertext), + nonce: Some(&nonce), + ..Default::default() + }; + let secret_id = db + .secrets() + .update(user_id, &existing_secret.id, update_secret) + .await?; + Some(secret_id) + } + None => { + let new_secret = NewChatRsSecret { + user_id, + name: &format!("{} API Key", provider.name), + ciphertext: &ciphertext, + nonce: &nonce, + }; + let secret_id = db.secrets().create(new_secret).await?; + Some(secret_id) + } + }; + } + + let update_provider = UpdateChatRsProvider { + api_key_id: secret_id, + name: input.name.as_deref(), + base_url: input.base_url.as_deref(), + default_model: input.default_model.as_deref(), + }; + let updated = db + .providers() + .update(user_id, provider_id, update_provider) + .await?; + + Ok(updated) + } + + pub async fn delete_provider( + &self, + db: &mut DbService, + user_id: &Uuid, + provider_id: i32, + ) -> Result { + let (_provider, _, api_key_secret) = self.get_provider(db, user_id, provider_id).await?; + if let Some(secret) = api_key_secret { + db.secrets().delete(user_id, &secret.id).await?; + } + let deleted = db.providers().delete(user_id, provider_id).await?; + + Ok(deleted) + } +} diff --git a/server-new/src/services/provider/types.rs b/server-new/src/services/provider/types.rs new file mode 100644 index 0000000..794957c --- /dev/null +++ b/server-new/src/services/provider/types.rs @@ -0,0 +1,22 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::db::models::{ChatRsProviderType, OpenAISubtype}; + +#[derive(Deserialize, JsonSchema)] +pub struct ProviderCreateInput { + pub(super) name: String, + pub(super) r#type: ChatRsProviderType, + pub(super) openai_type: Option, + pub(super) base_url: Option, + pub(super) default_model: String, + pub(super) api_key: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ProviderUpdateInput { + pub(super) name: Option, + pub(super) base_url: Option, + pub(super) default_model: Option, + pub(super) api_key: Option, +} diff --git a/server-new/src/services/storage/engines.rs b/server-new/src/services/storage/engines.rs new file mode 100644 index 0000000..e15b9f9 --- /dev/null +++ b/server-new/src/services/storage/engines.rs @@ -0,0 +1,7 @@ +//! Storage engines + +mod local; +mod s3; + +pub use local::LocalStorage; +pub use s3::{S3Config, S3Storage}; diff --git a/server-new/src/services/storage/engines/local.rs b/server-new/src/services/storage/engines/local.rs new file mode 100644 index 0000000..9883fa5 --- /dev/null +++ b/server-new/src/services/storage/engines/local.rs @@ -0,0 +1,95 @@ +use std::path::{Path, PathBuf}; + +use futures::{future::BoxFuture, stream::BoxStream}; +use tokio::{ + fs::File, + io::{AsyncReadExt, AsyncWriteExt, BufWriter}, +}; +use tokio_util::io::StreamReader; + +use crate::services::storage::{ + StorageEngine, + error::{StorageError, StorageResult}, +}; + +/// Default storage engine using local filesystem +pub struct LocalStorage { + base_path: PathBuf, +} + +impl LocalStorage { + pub fn new(base_path: PathBuf) -> Self { + Self { base_path } + } + + fn local_path(&self, file_path: &Path) -> PathBuf { + self.base_path.join(file_path) + } +} + +impl StorageEngine for LocalStorage { + fn create<'r>( + &'r self, + file_path: &'r Path, + _size: usize, + _content_type: &'r str, + stream: BoxStream<'static, Result>, + ) -> BoxFuture<'r, StorageResult> { + Box::pin(async move { + let local_path = self.local_path(file_path); + let dir = local_path.parent().expect("should always have parent dir"); + tokio::fs::create_dir_all(&dir).await?; + + let mut file = File::create_new(&local_path).await?; + let mut file_writer = BufWriter::new(&mut file); + let mut reader = StreamReader::new(stream); + let mut read_buffer = [0; 8192]; + let mut total_bytes: usize = 0; + + while let n = reader.read(&mut read_buffer).await? + && n != 0 + { + file_writer.write_all(&read_buffer[..n]).await?; + total_bytes += n; + } + + file_writer.flush().await?; + file.sync_all().await?; + + Ok(total_bytes) + }) + } + + fn exists<'r>(&'r self, file_path: &'r Path) -> BoxFuture<'r, StorageResult> { + Box::pin(async move { + let exists = tokio::fs::try_exists(&self.local_path(file_path)).await?; + + Ok(exists) + }) + } + + fn delete<'r>(&'r self, file_path: &'r Path) -> BoxFuture<'r, StorageResult<()>> { + Box::pin(async move { + let local_path = self.local_path(file_path); + if tokio::fs::try_exists(&local_path).await? { + return Err(StorageError::NotFound); + } + + tokio::fs::remove_file(&local_path).await?; + + // Clean up parent directories + let mut parent_dir = local_path.clone(); + while let Some(dir) = parent_dir.parent().filter(|dir| *dir != self.base_path) { + match tokio::fs::remove_dir(dir).await { + Ok(_) => parent_dir = dir.to_path_buf(), + Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => { + break; + } + Err(err) => return Err(StorageError::Io(err)), + }; + } + + Ok(()) + }) + } +} diff --git a/server-new/src/services/storage/engines/s3.rs b/server-new/src/services/storage/engines/s3.rs new file mode 100644 index 0000000..0123c37 --- /dev/null +++ b/server-new/src/services/storage/engines/s3.rs @@ -0,0 +1,154 @@ +use std::{ + path::{Path, PathBuf}, + sync::{Arc, atomic::AtomicUsize}, + time::Duration, +}; + +use futures::{TryStreamExt, future::BoxFuture, stream::BoxStream}; +use reqwest::{StatusCode, header}; +use rusty_s3::{Bucket, Credentials, S3Action}; +use serde::{Deserialize, Serialize}; + +use crate::services::storage::{ + StorageEngine, + error::{StorageError, StorageResult}, +}; + +/// Expiration used for S3 requests & presigned URLs +const EXPIRY: Duration = Duration::from_secs(60 * 5); + +/// S3 storage configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S3Config { + endpoint: reqwest::Url, + bucket: String, + region: String, + access_key: String, + secret_key: String, +} + +/// Storage engine using S3 +pub struct S3Storage<'c> { + base_path: PathBuf, + bucket: Bucket, + credentials: Credentials, + client: &'c reqwest::Client, +} + +impl<'c> S3Storage<'c> { + pub fn new( + base_path: PathBuf, + config: &'c S3Config, + client: &'c reqwest::Client, + ) -> StorageResult { + let bucket = Bucket::new( + config.endpoint.clone(), + rusty_s3::UrlStyle::VirtualHost, + config.bucket.clone(), + config.region.clone(), + ) + .map_err(|e| StorageError::Setup(e.to_string()))?; + let credentials = Credentials::new(&config.access_key, &config.secret_key); + + Ok(Self { + base_path, + bucket, + credentials, + client, + }) + } + + fn file_key(&self, path: &Path) -> Result { + let file_path = self.base_path.join(path); + let file_key = file_path + .to_str() + .ok_or_else(|| StorageError::InvalidPath(file_path.to_string_lossy().into_owned()))?; + + Ok(file_key.to_owned()) + } + + async fn handle_response_error(&self, response: reqwest::Response) -> StorageError { + StorageError::Response(format!( + "Status: {}, Response: {:?}", + response.status().as_u16(), + response.text().await + )) + } +} + +impl StorageEngine for S3Storage<'_> { + fn create<'r>( + &'r self, + path: &'r Path, + size: usize, + content_type: &'r str, + stream: BoxStream<'static, Result>, + ) -> BoxFuture<'r, StorageResult> { + Box::pin(async move { + let file_key = self.file_key(path)?; + let put_object = self.bucket.put_object(Some(&self.credentials), &file_key); + let url = put_object.sign(EXPIRY); + + let total_bytes = Arc::new(AtomicUsize::new(0)); + let size_counter = Arc::clone(&total_bytes); + + let response = self + .client + .put(url) + .header(header::CONTENT_LENGTH, size) + .header(header::CONTENT_TYPE, content_type) + .body(reqwest::Body::wrap_stream(stream.inspect_ok( + move |chunk| { + size_counter.fetch_add(chunk.len(), std::sync::atomic::Ordering::Relaxed); + }, + ))) + .send() + .await?; + + if !response.status().is_success() { + return Err(self.handle_response_error(response).await); + } + + Ok(total_bytes.load(std::sync::atomic::Ordering::Relaxed)) + }) + } + + fn exists<'r>(&'r self, path: &'r Path) -> BoxFuture<'r, StorageResult> { + Box::pin(async move { + let file_key = self.file_key(path)?; + let head_object = self.bucket.head_object(Some(&self.credentials), &file_key); + let url = head_object.sign(EXPIRY); + + let response = self.client.head(url).send().await?; + match response.status() { + StatusCode::OK => Ok(true), + StatusCode::NOT_FOUND => Ok(false), + _ => Err(self.handle_response_error(response).await), + } + }) + } + + fn delete<'r>(&'r self, path: &'r Path) -> BoxFuture<'r, StorageResult<()>> { + Box::pin(async move { + let file_key = self.file_key(path)?; + let delete_object = self + .bucket + .delete_object(Some(&self.credentials), &file_key); + let url = delete_object.sign(EXPIRY); + + let response = self.client.delete(url).send().await?; + match response.status() { + StatusCode::NO_CONTENT => Ok(()), + _ => Err(self.handle_response_error(response).await), + } + }) + } + + fn signed_url(&self, path: &Path) -> StorageResult> { + let file_key = self.file_key(path)?; + let get_object = self.bucket.get_object(Some(&self.credentials), &file_key); + let url = get_object.sign(EXPIRY); + + Ok(Some(url.into())) + } +} diff --git a/server-new/src/services/storage/error.rs b/server-new/src/services/storage/error.rs new file mode 100644 index 0000000..caad853 --- /dev/null +++ b/server-new/src/services/storage/error.rs @@ -0,0 +1,44 @@ +//! Storage operation errors + +use crate::{db::DbPoolError, error::AppError}; + +pub type StorageResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum StorageError { + #[error("File not found")] + NotFound, + #[error("File with this path already exists")] + AlreadyExists, + #[error("Unsupported content type: '{0}'")] + UnsupportedContentType(String), + #[error("Invalid file name/path: '{0}'")] + InvalidPath(String), + #[error("File had unexpected size: {0} bytes")] + WrongSize(usize), + + #[error("Storage setup error: {0}")] + Setup(String), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Storage request error: {0}")] + Request(#[from] reqwest::Error), + #[error("Storage response error: {0}")] + Response(String), + #[error("Database error: {0}")] + Database(#[from] diesel::result::Error), + #[error(transparent)] + DatabasePool(#[from] DbPoolError), +} + +impl From for AppError { + fn from(error: StorageError) -> Self { + match error { + StorageError::NotFound => AppError::not_found(error.to_string()), + StorageError::AlreadyExists + | StorageError::UnsupportedContentType(_) + | StorageError::InvalidPath(_) => Self::bad_request(error.to_string()), + err => Self::internal(err.into()), + } + } +} diff --git a/server-new/src/services/storage/interface.rs b/server-new/src/services/storage/interface.rs new file mode 100644 index 0000000..868b7ab --- /dev/null +++ b/server-new/src/services/storage/interface.rs @@ -0,0 +1,23 @@ +//! Storage interface + +use std::path::Path; + +use futures::{future::BoxFuture, stream::BoxStream}; + +use super::error::StorageResult; + +/// Trait representing an underlying storage to manage files for LLM chats and responses +pub trait StorageEngine: Send + Sync { + fn create<'r>( + &'r self, + path: &'r Path, + size: usize, + content_type: &'r str, + stream: BoxStream<'static, Result>, + ) -> BoxFuture<'r, StorageResult>; + fn exists<'r>(&'r self, path: &'r Path) -> BoxFuture<'r, StorageResult>; + fn delete<'r>(&'r self, path: &'r Path) -> BoxFuture<'r, StorageResult<()>>; + fn signed_url(&self, #[allow(unused)] path: &Path) -> StorageResult> { + Ok(None) + } +} diff --git a/server-new/src/services/storage/mod.rs b/server-new/src/services/storage/mod.rs new file mode 100644 index 0000000..4380151 --- /dev/null +++ b/server-new/src/services/storage/mod.rs @@ -0,0 +1,182 @@ +use std::path::{Path, PathBuf}; + +use futures::Stream; +use uuid::Uuid; + +use crate::{ + config::StorageConfig, + db::{ + DbPool, DbService, + models::{ChatRsFile, ChatRsFileType, NewChatRsFile}, + }, + services::storage::{ + engines::{LocalStorage, S3Storage}, + error::{StorageError, StorageResult}, + }, +}; + +pub mod engines; +mod error; +mod interface; + +pub use interface::StorageEngine; + +/// Name of the base folder containing all files/attachments +pub const STORAGE_FOLDER: &str = "rs-chat/storage"; +/// Name of the folder containing user files +pub const USER_FOLDER: &str = "user"; +/// Name of the folder containing session files +pub const SESSION_FOLDER: &str = "session"; + +pub struct StorageService<'r> { + data_dir: &'r Path, + db_pool: &'r DbPool, + config: &'r StorageConfig, + http_client: &'r reqwest::Client, +} + +impl<'r> StorageService<'r> { + pub fn new( + data_dir: &'r Path, + db_pool: &'r DbPool, + http_client: &'r reqwest::Client, + config: &'r StorageConfig, + ) -> Self { + Self { + data_dir, + db_pool, + config, + http_client, + } + } + + pub async fn create_file( + &self, + user_id: &Uuid, + session_id: Option<&Uuid>, + path: &str, + size: usize, + content_type: &str, + stream: impl Stream> + Send + 'static, + ) -> StorageResult { + let file_path = self.build_file_path(user_id, session_id, &path)?; + let file_type = self.validate_file_type(content_type)?; + + let storage = self.storage_engine()?; + if storage.exists(&file_path).await? { + return Err(StorageError::AlreadyExists); + } + + match storage + .create(&file_path, size, content_type, Box::pin(stream)) + .await + { + Ok(bytes_written) if bytes_written == size => {} + Ok(wrong_size) => { + let _ = storage.delete(&file_path).await; + return Err(StorageError::WrongSize(wrong_size)); + } + Err(err) => { + let _ = storage.delete(&file_path).await; + return Err(err); + } + }; + + let mut db = DbService::from_pool(self.db_pool).await?; + let new_file = NewChatRsFile { + user_id, + session_id, + path, + file_type: file_type.as_ref(), + content_type, + size: size.try_into().unwrap_or_default(), + }; + let db_file = db.files().create_file(new_file).await?; + + Ok(db_file) + } + + pub async fn delete_file( + &self, + db: &mut DbService, + user_id: &Uuid, + session_id: Option<&Uuid>, + file_id: &Uuid, + ) -> StorageResult { + let db_file = match session_id { + Some(session_id) => { + db.files() + .find_session_file(user_id, session_id, file_id) + .await? + } + None => db.files().find_user_file(user_id, file_id).await?, + } + .ok_or(StorageError::NotFound)?; + + let storage = self.storage_engine()?; + let file_path = self.build_file_path(user_id, session_id, &db_file.path)?; + if let Err(err) = storage.delete(&file_path).await { + tracing::warn!("error deleting file {file_id} with path {file_path:?}: {err}"); + } + + let deleted_file_id = match session_id { + Some(session_id) => { + db.files() + .delete_session_file(user_id, session_id, file_id) + .await? + } + None => db.files().delete_user_file(user_id, file_id).await?, + }; + + Ok(deleted_file_id) + } + + fn storage_engine(&self) -> StorageResult> { + Ok(match self.config { + StorageConfig::Local => Box::new(LocalStorage::new(self.data_dir.join(STORAGE_FOLDER))), + StorageConfig::S3(config) => Box::new(S3Storage::new( + STORAGE_FOLDER.into(), + config, + self.http_client, + )?), + }) + } + + fn build_file_path( + &self, + user_id: &Uuid, + session_id: Option<&Uuid>, + path: &str, + ) -> StorageResult { + let valid_path = Path::new(path).is_relative() + && Path::new(path) + .components() + .all(|c| matches!(c, std::path::Component::Normal(_))); + if !valid_path { + return Err(StorageError::InvalidPath(path.into())); + } + + Ok(match session_id { + Some(session_id) => { + let segments = [ + &user_id.to_string(), + SESSION_FOLDER, + &session_id.to_string(), + &path, + ]; + segments.iter().collect() + } + None => [&user_id.to_string(), USER_FOLDER, &path].iter().collect(), + }) + } + + fn validate_file_type(&self, content_type: &str) -> StorageResult { + match content_type { + "image/jpeg" | "image/png" | "image/webp" => Ok(ChatRsFileType::Image), + "application/pdf" => Ok(ChatRsFileType::Pdf), + "application/json" | "application/xml" => Ok(ChatRsFileType::Text), + text if text.starts_with("text/") => Ok(ChatRsFileType::Text), + unsupported => Err(StorageError::UnsupportedContentType(unsupported.into())), + } + } +} diff --git a/server-new/src/services/stream/error.rs b/server-new/src/services/stream/error.rs new file mode 100644 index 0000000..bb5c252 --- /dev/null +++ b/server-new/src/services/stream/error.rs @@ -0,0 +1,8 @@ +/// Streaming infrastructure errors +#[derive(Debug, thiserror::Error)] +pub enum StreamingError { + #[error("tinistream error: {0}")] + Tinistream(#[from] super::tinistream::TiniError), + #[error("websocket error: {0}")] + Websocket(#[from] reqwest_websocket::Error), +} diff --git a/server-new/src/services/stream/mod.rs b/server-new/src/services/stream/mod.rs new file mode 100644 index 0000000..6f6013d --- /dev/null +++ b/server-new/src/services/stream/mod.rs @@ -0,0 +1,138 @@ +use std::time::{Duration, Instant}; + +use futures::{ + StreamExt, + stream::{SplitSink, SplitStream}, +}; +use reqwest_websocket::WebSocket; +use tinistream::TinistreamClient; +use tinistream_client::types::{StreamAccessResponse, StreamInfo, StreamStatus}; +use uuid::Uuid; + +pub mod error; +pub mod tinistream; +mod writer; + +#[cfg(test)] +mod tests; + +use crate::{ + db::models::ChatRsLogStatus, + llm::{interface::LlmStream, types::LlmUsage}, + services::stream::error::StreamingError, +}; + +/// Handles stream processing and interacting with `tinistream` for streaming to users +pub struct StreamingService<'r> { + tinistream: &'r TinistreamClient, +} + +/// Complete, accumulated response from the LLM provider stream +pub struct LlmStreamOutput { + pub text: Option, + // pub tool_calls: Option>, + // pub images: Option>, + pub usage: Option, + pub errors: Option>, + pub first_token_in: Option, + pub cancelled: bool, +} +impl LlmStreamOutput { + /// Get the logged status for this response + pub fn status(&self) -> ChatRsLogStatus { + if self.cancelled { + ChatRsLogStatus::Cancelled + } else if self.errors.as_ref().is_some_and(|e| !e.is_empty()) { + ChatRsLogStatus::Error + } else { + ChatRsLogStatus::Completed + } + } +} + +type WsWriter = SplitSink; +type WsReader = SplitStream; + +impl<'r> StreamingService<'r> { + pub fn new(tinistream: &'r TinistreamClient) -> Self { + Self { tinistream } + } + + /// Get the Redis key of the chat stream for the given user and session ID + pub fn chat_stream_key(user_id: &Uuid, session_id: &Uuid) -> String { + format!("{}{}", Self::chat_stream_prefix(user_id), session_id) + } + + /// Get the Redis key prefix for the user's chat streams + pub fn chat_stream_prefix(user_id: &Uuid) -> String { + format!("user:{user_id}:chat:") + } + + /// Generate a Redis key for a user's prompt + pub fn prompt_key(user_id: &Uuid) -> String { + format!("user:{user_id}:prompt:{}", Uuid::new_v4()) + } + + /// Extract the session ID from the user's stream key + pub fn session_id_from_stream_key(key: &str, key_prefix: &str) -> Option { + key.strip_prefix(key_prefix) + .and_then(|session_id| Uuid::try_parse(session_id).ok()) + } + + /// Check for existing active client stream + pub async fn exists_stream(&self, stream_key: &str) -> Result { + Ok(self.tinistream.stream_exists(stream_key).await?) + } + + /// Currently active streams with the given prefix + pub async fn active_streams(&self, prefix: &str) -> Result, StreamingError> { + let streams = self + .tinistream + .active_streams(&format!("{prefix}*",)) + .await?; + + Ok(streams) + } + + /// Start the client stream, and return a WebSocket writer and reader for it + pub async fn create_stream( + &self, + stream_key: &str, + ) -> Result<(StreamAccessResponse, WsWriter, WsReader), StreamingError> { + let stream_access = self.tinistream.stream_start(stream_key).await?; + let (writer, reader) = self.tinistream.stream_writer_ws(stream_key).await?.split(); + + Ok((stream_access, writer, reader)) + } + + /// Get access to an ongoing client stream + pub async fn access_stream( + &self, + stream_key: &str, + ) -> Result { + Ok(self.tinistream.stream_connect(stream_key).await?) + } + + /// Process and write the LLM response stream via the WebSocket connection, + /// and return the accumulated response. + pub async fn process_stream( + stream: LlmStream, + start_time: Instant, + writer: WsWriter, + reader: WsReader, + ) -> LlmStreamOutput { + writer::LlmStreamWriter::new() + .process(stream, start_time, writer, reader) + .await + } + + /// Signal end of stream + pub async fn end_stream(&self, stream_key: &str) -> Result { + Ok(self.tinistream.stream_end(stream_key).await?) + } + + /// Signal stream cancellation + pub async fn cancel_stream(&self, stream_key: &str) -> Result { + Ok(self.tinistream.stream_cancel(stream_key).await?) + } +} diff --git a/server-new/src/services/stream/tests/mod.rs b/server-new/src/services/stream/tests/mod.rs new file mode 100644 index 0000000..2046c4e --- /dev/null +++ b/server-new/src/services/stream/tests/mod.rs @@ -0,0 +1,252 @@ +// use futures::StreamExt; +// use reqwest_websocket::WebSocket; +// use uuid::Uuid; + +// use crate::services::stream::{StreamService, writer::LlmStreamWriter}; + +// mod utils; +// use utils::*; + +// async fn create_test_writer( +// user_id: &Uuid, +// session_id: &Uuid, +// ) -> (String, WebSocket, LlmStreamWriter) { +// let key = StreamService::chat_stream_key(user_id, session_id); +// let tini = setup_tini_client(); +// tini.stream_start(&key).await.expect("should start stream"); +// let ws = tini +// .stream_writer_ws(&key) +// .await +// .expect("should connect to WebSocket for adding events"); + +// (key, ws, LlmStreamWriter::new()) +// } + +// #[tokio::test] +// async fn stream_writer_basic_functionality() { +// let user_id = Uuid::new_v4(); +// let session_id = Uuid::new_v4(); +// let tini = setup_tini_client(); +// let (key, ws, mut writer) = create_test_writer(&user_id, &session_id).await; +// let (ws_writer, ws_reader) = ws.split(); + +// // Create stream +// assert!(tini.stream_exists(&key).await.unwrap()); + +// // Create Lorem provider and get stream +// let lorem = LoremProvider::new(); +// let stream = lorem +// .chat_stream(vec![], None, &LlmProviderOptions::default()) +// .await +// .expect("Failed to create lorem stream"); + +// // Process the stream +// let LlmOutput { +// text, +// tool_calls, +// usage, +// errors, +// cancelled, +// .. +// } = writer.process(stream, ws_writer, ws_reader).await; + +// // Verify results +// assert!(text.is_some()); +// let text = text.unwrap(); +// assert!(!text.is_empty()); +// assert!(text.contains("Lorem ipsum")); +// assert!(text.contains("dolor sit")); + +// assert!(tool_calls.is_none()); +// assert!(usage.is_none()); +// assert!(errors.is_some()); // Lorem provider generates some test errors +// assert!(!cancelled); + +// // End stream +// assert!(tini.stream_end(&key).await.is_ok()); + +// // Stream should be deleted after end +// assert!(!tini.stream_exists(&key).await.unwrap()); +// } + +// #[tokio::test] +// async fn stream_writer_batching() { +// let user_id = Uuid::new_v4(); +// let session_id = Uuid::new_v4(); +// let tini = setup_tini_client(); +// let (key, ws, mut writer) = create_test_writer(&user_id, &session_id).await; +// let (ws_writer, ws_reader) = ws.split(); + +// // Create a custom stream with small chunks to test batching +// let chunks = vec![ +// "Hello", " ", "world", "!", " ", "This", " ", "is", " ", "a", " ", "test", +// ]; +// let chunk_stream = tokio_stream::iter( +// chunks +// .into_iter() +// .map(|text| Ok(LlmStreamChunk::Text(text.into()))), +// ); + +// let stream: LlmStream = Box::pin(chunk_stream); +// let LlmOutput { +// text, cancelled, .. +// } = writer.process(stream, ws_writer, ws_reader).await; + +// assert!(text.is_some()); +// let text = text.unwrap(); +// assert_eq!(text, "Hello world! This is a test"); +// assert!(!cancelled); + +// tini.stream_end(&key).await.ok(); +// } + +// #[tokio::test] +// async fn stream_writer_error_handling() { +// let user_id = Uuid::new_v4(); +// let session_id = Uuid::new_v4(); +// let tini = setup_tini_client(); +// let (key, ws, mut writer) = create_test_writer(&user_id, &session_id).await; +// let (ws_writer, ws_reader) = ws.split(); + +// // Create a stream that produces an error +// let error_stream = tokio_stream::iter(vec![ +// Ok(LlmStreamChunk::Text("Hello".to_string())), +// Err(LlmStreamError::ProviderError("Test error".into())), +// Ok(LlmStreamChunk::Text(" World".to_string())), +// ]); + +// let stream: LlmStream = Box::pin(error_stream); +// let LlmOutput { +// text, +// errors, +// cancelled, +// .. +// } = writer.process(stream, ws_writer, ws_reader).await; + +// assert!(text.is_some()); +// let text = text.unwrap(); +// assert_eq!(text, "Hello World"); + +// assert!(errors.is_some()); +// let errors = errors.unwrap(); +// assert!(!errors.is_empty()); +// assert!(errors.iter().any(|e| e.contains("Test error"))); + +// assert!(!cancelled); + +// tini.stream_end(&key).await.ok(); +// } + +// #[tokio::test] +// async fn stream_writer_cancel() { +// let user_id = Uuid::new_v4(); +// let session_id = Uuid::new_v4(); +// let tini = setup_tini_client(); +// let (key, ws, mut writer) = create_test_writer(&user_id, &session_id).await; +// let (ws_writer, ws_reader) = ws.split(); + +// assert!(tini.stream_exists(&key).await.unwrap()); + +// let stream = LoremProvider::new() +// .chat_stream(vec![], None, &LlmProviderOptions::default()) +// .await +// .expect("Failed to create lorem stream"); +// let process_fut = writer.process(stream, ws_writer, ws_reader); + +// // Cancel the stream after 2 seconds +// tokio::time::sleep(std::time::Duration::from_secs(2)).await; +// tini.stream_cancel(&key).await.unwrap(); + +// // process() response should show that stream was cancelled +// let LlmOutput { +// errors, cancelled, .. +// } = process_fut.await; +// assert!(cancelled); +// assert!(errors.unwrap().last().unwrap().contains("cancelled")); + +// // Stream should be deleted after cancel +// assert!(!tini.stream_exists(&key).await.unwrap()); +// } + +// #[tokio::test] +// async fn stream_writer_usage_tracking() { +// let user_id = Uuid::new_v4(); +// let session_id = Uuid::new_v4(); +// let tini = setup_tini_client(); +// let (key, ws, mut writer) = create_test_writer(&user_id, &session_id).await; +// let (ws_writer, ws_reader) = ws.split(); + +// assert!(tini.stream_exists(&key).await.unwrap()); + +// // Create a stream with usage information +// let usage_stream = tokio_stream::iter(vec![ +// Ok(LlmStreamChunk::Text("Hello".into())), +// Ok(LlmStreamChunk::Usage(LlmUsage { +// input_tokens: Some(10), +// output_tokens: Some(5), +// cost: Some(0.001), +// })), +// Ok(LlmStreamChunk::Text(" World".into())), +// Ok(LlmStreamChunk::Usage(LlmUsage { +// input_tokens: None, // Should not override +// output_tokens: Some(7), // Should update +// cost: Some(0.002), // Should update +// })), +// ]); + +// let stream: LlmStream = Box::pin(usage_stream); +// let LlmOutput { +// text, +// usage, +// cancelled, +// .. +// } = writer.process(stream, ws_writer, ws_reader).await; + +// assert!(text.is_some()); +// assert_eq!(text.unwrap(), "Hello World"); + +// assert!(usage.is_some()); +// let usage = usage.unwrap(); +// assert_eq!(usage.input_tokens, Some(10)); +// assert_eq!(usage.output_tokens, Some(7)); +// assert_eq!(usage.cost, Some(0.002)); + +// assert!(!cancelled); + +// tini.stream_end(&key).await.ok(); +// } + +// #[tokio::test] +// async fn redis_stream_entries() { +// let user_id = Uuid::new_v4(); +// let session_id = Uuid::new_v4(); +// let tini = setup_tini_client(); +// let (key, ws, mut writer) = create_test_writer(&user_id, &session_id).await; +// let (ws_writer, ws_reader) = ws.split(); + +// assert!(tini.stream_exists(&key).await.unwrap()); + +// // Verify start event was written +// let info = tini +// .stream_info(&key) +// .await +// .expect("Failed to check stream") +// .expect("Stream not found"); +// assert_eq!(info.length, 1); + +// // Create a simple stream +// let stream = tokio_stream::iter(vec![Ok(LlmStreamChunk::Text("Test chunk".into()))]).boxed(); +// writer.process(stream, ws_writer, ws_reader).await; +// drop(writer); + +// // Should have start + text entries +// tokio::time::sleep(std::time::Duration::from_secs(1)).await; +// let info = tini +// .stream_info(&key) +// .await +// .expect("Failed to check stream") +// .expect("Stream not found"); +// assert_eq!(info.length, 2); + +// tini.stream_end(&key).await.ok(); +// } diff --git a/server-new/src/services/stream/tests/utils.rs b/server-new/src/services/stream/tests/utils.rs new file mode 100644 index 0000000..3fa89b4 --- /dev/null +++ b/server-new/src/services/stream/tests/utils.rs @@ -0,0 +1,15 @@ +use super::super::tinistream::TinistreamClient; + +pub fn setup_tini_client() -> TinistreamClient { + let url = dotenvy::var("RS_CHAT_TINISTREAM_URL").unwrap_or("http://127.0.0.1:8081".to_owned()); + let api_key = dotenvy::var("RS_CHAT_TINISTREAM_API_KEY").unwrap_or("".to_owned()); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("X-API-KEY", api_key.parse().expect("Should be valid")); + let tini_http_client = reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .expect("Failed to build tinistream HTTP client"); + + let tini_client = tinistream_client::Client::new_with_client(&url, tini_http_client.clone()); + TinistreamClient::new(tini_client) +} diff --git a/server-new/src/services/stream/tinistream.rs b/server-new/src/services/stream/tinistream.rs new file mode 100644 index 0000000..639ec46 --- /dev/null +++ b/server-new/src/services/stream/tinistream.rs @@ -0,0 +1,146 @@ +//! Client for `tinistream` to handle streaming responses + +use reqwest_websocket::{Upgrade, WebSocket}; +use tinistream_client::{Client, ClientInfo, ClientStreamExt, Error, types::*}; + +/// A client for interacting with the `tinistream` API. +#[derive(Debug, Clone)] +pub struct TinistreamClient { + client: Client, +} + +/// Result type for tinistream API operations. +pub type TiniResult = Result; + +#[derive(Debug, thiserror::Error)] +#[error("{status} {message}")] +pub struct TiniError { + pub status: u16, + pub message: String, +} + +impl TinistreamClient { + pub fn new(client: Client) -> Self { + Self { client } + } + + /// Test the connection to the tinistream server + pub async fn ping(&self) -> TiniResult<()> { + match self.client.health().send().await { + Ok(_) => Ok(()), + Err(err) => Err(TiniError { + status: err.status().map(|s| s.as_u16()).unwrap_or(500), + message: err.to_string(), + }), + } + } + + /// Returns a list of keys with the given prefix that have an active stream. + pub async fn active_streams(&self, prefix: &str) -> TiniResult> { + let streams = self + .client + .list_streams() + .pattern(format!("{prefix}*")) + .send() + .await? + .into_inner(); + Ok(streams) + } + + /// Returns whether an active chat stream exists for the given key. + pub async fn stream_exists(&self, key: &str) -> TiniResult { + match self.client.get_stream_info().key(key).send().await { + Ok(_) => Ok(true), + Err(err) => match err.status() { + Some(reqwest::StatusCode::NOT_FOUND) => Ok(false), + _ => Err(err.into()), + }, + } + } + + /// Returns info about a chat stream at the given key. + pub async fn stream_info(&self, key: &str) -> TiniResult> { + let info = self.client.get_stream_info().key(key).send().await?; + Ok(Some(info.into_inner())) + } + + /// Start the chat stream and get the client URL and access token + pub async fn stream_start(&self, key: &str) -> TiniResult { + let res = self + .client + .create_stream() + .body(StreamRequest::builder().key(key)) + .send() + .await?; + + Ok(res.into_inner()) + } + + /// Get URL and token for a client to access a stream + pub async fn stream_connect(&self, key: &str) -> TiniResult { + let res = self + .client + .create_token() + .body(StreamRequest::builder().key(key)) + .send() + .await?; + + Ok(res.into_inner()) + } + + /// Get a WebSocket connection to write to a stream + pub async fn stream_writer_ws(&self, key: &str) -> Result { + let http_client = self.client.client(); + let res = http_client + .get(format!("{}/api/event/add/ws-stream", self.client.baseurl())) + .query(&[("key", key)]) + .upgrade() + .send() + .await?; + + res.into_websocket().await + } + + /// Cancel a stream + pub async fn stream_cancel(&self, key: &str) -> TiniResult { + let res = self + .client + .cancel_stream() + .body(StreamRequest::builder().key(key)) + .send() + .await?; + + Ok(res.into_inner().status) + } + + /// Signal the end of a stream + pub async fn stream_end(&self, key: &str) -> TiniResult { + let res = self + .client + .end_stream() + .body(StreamRequest::builder().key(key)) + .send() + .await?; + + Ok(res.into_inner().status) + } +} + +impl From> for TiniError { + fn from(value: Error) -> Self { + match value { + Error::ErrorResponse(res) => { + let status = res.status().as_u16(); + let res = res.into_inner(); + TiniError { + status, + message: res.error.message, + } + } + res => TiniError { + status: res.status().map_or(500, |s| s.as_u16()), + message: res.to_string(), + }, + } + } +} diff --git a/server-new/src/services/stream/writer.rs b/server-new/src/services/stream/writer.rs new file mode 100644 index 0000000..4f49280 --- /dev/null +++ b/server-new/src/services/stream/writer.rs @@ -0,0 +1,265 @@ +use std::time::{Duration, Instant}; + +use futures::{SinkExt, StreamExt}; +use reqwest_websocket::Message as WsMessage; +use serde::Serialize; +use tokio_util::sync::CancellationToken; + +use crate::{ + llm::{ + error::LlmStreamChunkError, + interface::{LlmStream, LlmStreamChunk}, + types::LlmUsage, + }, + services::stream::{LlmStreamOutput, WsReader, WsWriter, error::StreamingError}, +}; + +/// Interval at which chunks are flushed to the Redis stream. +const FLUSH_INTERVAL: Duration = Duration::from_millis(400); +/// Max # of characters of the text chunk before it is automatically flushed to Redis. +const MAX_CHUNK_SIZE: usize = 75; + +/// Utility for processing an incoming LLM response stream and writing chunks to `tinistream`. +#[derive(Debug)] +pub struct LlmStreamWriter { + /// The current chunk of data being processed. + current_chunk: ChunkState, + /// Accumulated text response from the assistant. + complete_text: Option, + /// Accumulated tool calls from the assistant. + // tool_calls: Option>, + /// Accumulated generated images from the assistant. + // images: Option>, + /// Accumulated errors during the stream from the LLM provider. + errors: Option>, + /// Accumulated usage information from the LLM provider. + usage: Option, + /// Duration from request start to first token + first_token_in: Option, +} + +/// Internal state +#[derive(Debug, Default)] +struct ChunkState { + text: Option, + // tool_calls: Option>, + // pending_tool_calls: Option>, + error: Option, +} + +/// Chunk of the LLM response stored in the Redis stream. +#[derive(Debug, Serialize)] +#[serde(tag = "event", content = "data", rename_all = "snake_case")] +pub(super) enum RedisStreamChunk { + Text(String), + // ToolCall(String), + // PendingToolCall(String), + Error(String), +} + +impl LlmStreamWriter { + pub fn new() -> Self { + LlmStreamWriter { + current_chunk: ChunkState::default(), + complete_text: None, + // tool_calls: None, + // images: None, + errors: None, + usage: None, + first_token_in: None, + } + } + + /// Process the incoming stream from the LLM provider, intermittently flushing + /// chunks to `tinistream` via the WebSocket connection, and return the final + /// accumulated response. + pub async fn process( + &mut self, + stream: LlmStream, + start_time: Instant, + mut writer: WsWriter, + mut reader: WsReader, + ) -> LlmStreamOutput { + let mut cancelled = false; + + // Spawn task to listen for stream cancellation + let cancel_token = CancellationToken::new(); + let cancel_task_token = cancel_token.clone(); + let cancel_task = tokio::spawn(async move { + while let Some(res) = reader.next().await { + if let Ok(WsMessage::Close { .. }) = res { + cancel_task_token.cancel(); + } + } + }); + + tokio::select! { + _ = self.process_stream(stream, start_time, &mut writer) => {} + _ = cancel_token.cancelled() => { + self.errors.get_or_insert_default().push(LlmStreamChunkError::StreamCancelled); + cancelled = true; + } + } + + cancel_task.abort(); + writer.close().await.ok(); + + LlmStreamOutput { + text: self.complete_text.take(), + // tool_calls: self.tool_calls.take(), + // images: self.images.take(), + usage: self.usage.take(), + errors: self.errors.take().map(|e| { + e.into_iter() + .map(|e| e.to_string()) + .collect::>() + }), + first_token_in: self.first_token_in.take(), + cancelled, + } + } + + async fn process_stream( + &mut self, + mut stream: LlmStream, + start_time: Instant, + writer: &mut WsWriter, + ) { + let mut last_flushed_at = Instant::now(); + loop { + match stream.next().await { + Some(Ok(chunk)) => match chunk { + LlmStreamChunk::Text(text) => { + if self.first_token_in.is_none() { + self.first_token_in = Some(start_time.elapsed()); + } + self.process_text(&text); + } + // LlmStreamChunk::ToolCalls(tool_calls) => self.process_tool_calls(tool_calls), + // LlmStreamChunk::PendingToolCall(pending_tool_call) => { + // self.process_pending_tool_call(pending_tool_call) + // } + // LlmStreamChunk::Images(images) => self.process_images(images), + LlmStreamChunk::Usage(usage) => self.process_usage(usage), + }, + Some(Err(err)) => self.process_error(err), + None => break, + } + + if self.should_flush(&last_flushed_at) { + if let Err(err) = self.flush_chunks(writer).await { + self.process_error(LlmStreamChunkError::from(err)); + } + last_flushed_at = Instant::now(); + } + } + + if let Err(err) = self.flush_chunks(writer).await { + self.process_error(LlmStreamChunkError::from(err)); + } + } + + fn process_text(&mut self, text: &str) { + self.current_chunk + .text + .get_or_insert_with(|| String::with_capacity(MAX_CHUNK_SIZE * 2)) + .push_str(text); + self.complete_text + .get_or_insert_with(|| String::with_capacity(1024)) + .push_str(text); + } + + // fn process_tool_calls(&mut self, tool_calls: Vec) { + // self.current_chunk + // .tool_calls + // .get_or_insert_default() + // .extend(tool_calls.clone()); + // self.tool_calls.get_or_insert_default().extend(tool_calls); + // } + + // fn process_pending_tool_call(&mut self, tool_call: LlmPendingToolCall) { + // let current_chunk = self + // .current_chunk + // .pending_tool_calls + // .get_or_insert_default(); + // if !current_chunk.iter().any(|tc| tc.index == tool_call.index) { + // current_chunk.push(tool_call); + // } + // } + + // fn process_images(&mut self, images: Vec) { + // self.images.get_or_insert_default().extend(images); + // } + + fn process_usage(&mut self, usage_chunk: LlmUsage) { + let usage = self.usage.get_or_insert_default(); + if let Some(input_tokens) = usage_chunk.input_tokens { + usage.input_tokens = Some(input_tokens); + } + if let Some(output_tokens) = usage_chunk.output_tokens { + usage.output_tokens = Some(output_tokens); + } + if let Some(cost) = usage_chunk.cost { + usage.cost = Some(cost); + } + } + + fn process_error(&mut self, err: LlmStreamChunkError) { + self.current_chunk.error = Some(err.to_string()); + self.errors.get_or_insert_default().push(err); + } + + fn should_flush(&self, last_flushed_at: &Instant) -> bool { + // if self.current_chunk.tool_calls.is_some() || self.current_chunk.error.is_some() { + // return true; + // } + if self.current_chunk.error.is_some() { + return true; + } + let text = self.current_chunk.text.as_ref(); + last_flushed_at.elapsed() > FLUSH_INTERVAL || text.is_some_and(|t| t.len() > MAX_CHUNK_SIZE) + } + + /// Flushes the current chunk(s) to the Redis stream. + pub(super) async fn flush_chunks( + &mut self, + ws_writer: &mut WsWriter, + ) -> Result<(), StreamingError> { + let chunk_state = std::mem::take(&mut self.current_chunk); + + if let Some(text) = chunk_state.text { + self.add_to_stream(ws_writer, RedisStreamChunk::Text(text)) + .await?; + } + // if let Some(tool_calls) = chunk_state.tool_calls { + // for tool_call in tool_calls { + // let tool_call_str = serde_json::to_string(&tool_call).unwrap_or_default(); + // let entry = RedisStreamChunk::ToolCall(tool_call_str); + // self.add_to_stream(ws_writer, entry).await?; + // } + // } + // if let Some(pending_tool_calls) = chunk_state.pending_tool_calls { + // for tool_call in pending_tool_calls { + // let tool_call_str = serde_json::to_string(&tool_call).unwrap_or_default(); + // let entry = RedisStreamChunk::PendingToolCall(tool_call_str); + // self.add_to_stream(ws_writer, entry).await?; + // } + // } + if let Some(error) = chunk_state.error { + self.add_to_stream(ws_writer, RedisStreamChunk::Error(error)) + .await?; + } + + Ok(ws_writer.flush().await?) + } + + /// Serialize and add an entry to Redis via the WebSocket connection (does not flush the connection) + async fn add_to_stream( + &mut self, + ws_writer: &mut WsWriter, + entry: RedisStreamChunk, + ) -> Result<(), StreamingError> { + let message = WsMessage::text_from_json(&entry)?; + Ok(ws_writer.feed(message).await?) + } +} diff --git a/server-new/src/state.rs b/server-new/src/state.rs new file mode 100644 index 0000000..223306a --- /dev/null +++ b/server-new/src/state.rs @@ -0,0 +1,72 @@ +//! Application state + +use std::{ops::Deref, sync::Arc}; + +use axum_plugin::{AppState, TypeMap}; + +use crate::{ + config::AppConfig, + db::DbPool, + services::{ + auth::{AuthService, encryption::Encryptor, oauth::OAuthProviderMap}, + chat::ChatService, + model::ModelService, + provider::ProviderService, + storage::StorageService, + stream::tinistream::TinistreamClient, + }, +}; + +/// App state stored in the Axum router +#[derive(Clone)] +pub struct AppState(Arc); + +#[derive(AppState)] +pub struct AppStateInner { + pub config: Arc, + pub db_pool: DbPool, + pub encryptor: Encryptor, + pub http_client: reqwest::Client, + pub oauth_providers: OAuthProviderMap, + pub redis: fred::prelude::Pool, + pub tinistream: TinistreamClient, +} + +impl AppState { + pub fn auth_service(&self) -> AuthService<'_> { + AuthService::new(&self.config, &self.encryptor, &self.oauth_providers) + } + pub fn chat_service(&self) -> ChatService<'_> { + ChatService::new(&self.db_pool, &self.tinistream) + } + pub fn provider_service(&self) -> ProviderService<'_> { + ProviderService::new(&self.encryptor, &self.http_client) + } + pub fn model_service(&self) -> ModelService<'_> { + ModelService::new(&self.redis, &self.http_client) + } + pub fn storage_service(&self) -> StorageService<'_> { + StorageService::new( + &self.config.server.data_dir, + &self.db_pool, + &self.http_client, + &self.config.storage, + ) + } +} + +impl Deref for AppState { + type Target = AppStateInner; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for AppState { + type Error = anyhow::Error; + + fn try_from(map: TypeMap) -> Result { + Ok(Self(Arc::new(AppStateInner::try_from(map)?))) + } +} diff --git a/server/.env.example b/server/.env.example index 69f7954..3c4cff7 100644 --- a/server/.env.example +++ b/server/.env.example @@ -8,10 +8,19 @@ RS_CHAT_GITHUB_CLIENT_SECRET=your_github_client_secret_here # Generate a 64-character hex key for encryption # You can generate one with: openssl rand -hex 32 -RS_CHAT_SECRET_KEY=hex-secret-key-for-encryption-change-this +RS_CHAT_SECRET_KEY=64-character-hex-secret-key-change-this # Local data directory RS_CHAT_DATA_DIR=.local +# Tinistream service (client streaming utility) +RS_CHAT_TINISTREAM_API_KEY=api-key-123 +STREAMER_API_KEY=api-key-123 +STREAMER_SECRET_KEY=64-character-hex-secret-key-change-this + +# Tinirun service (code runner) +RS_CHAT_TINIRUN_API_KEY=api-key-123 +RUNNER_API_KEY=api-key-123 + # Postgres URL for running migrations via the Diesel CLI DATABASE_URL=postgres://postgres:postgres@localhost/postgres diff --git a/server/Cargo.lock b/server/Cargo.lock index f4f21b9..3bb2ec5 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -84,22 +84,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "astral-tokio-tar" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" -dependencies = [ - "filetime", - "futures-core", - "libc", - "portable-atomic", - "rustc-hash", - "tokio", - "tokio-stream", - "xattr", -] - [[package]] name = "async-io" version = "2.6.0" @@ -164,9 +148,9 @@ dependencies = [ [[package]] name = "async-tungstenite" -version = "0.31.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" dependencies = [ "atomic-waker", "futures-core", @@ -253,57 +237,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bollard" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" -dependencies = [ - "base64 0.22.1", - "bollard-stubs", - "bytes", - "futures-core", - "futures-util", - "hex", - "home", - "http 1.4.0", - "http-body-util", - "hyper 1.8.1", - "hyper-named-pipe", - "hyper-rustls 0.27.7", - "hyper-util", - "hyperlocal", - "log", - "pin-project-lite", - "rustls 0.23.36", - "rustls-native-certs 0.8.3", - "rustls-pemfile 2.2.0", - "rustls-pki-types", - "serde", - "serde_derive", - "serde_json", - "serde_repr", - "serde_urlencoded", - "thiserror", - "tokio", - "tokio-util", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-stubs" -version = "1.49.1-rc.28.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" -dependencies = [ - "serde", - "serde_json", - "serde_repr", - "serde_with", -] - [[package]] name = "bon" version = "3.9.0" @@ -375,6 +308,12 @@ dependencies = [ "either", ] +[[package]] +name = "cargo-husky" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b02b629252fe8ef6460461409564e2c21d0c8e77e0944f3d189ff06c4e932ad" + [[package]] name = "cc" version = "1.2.56" @@ -391,21 +330,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chat-rs-api" version = "0.7.0" dependencies = [ "aes-gcm", - "astral-tokio-tar", "base64 0.22.1", - "bollard", - "bon", "chrono", "const_format", "diesel", @@ -431,6 +361,7 @@ dependencies = [ "serde_json", "subst", "thiserror", + "tinirun-client", "tinistream-client", "tokio", "tokio-stream", @@ -594,6 +525,16 @@ dependencies = [ "darling_macro 0.13.4", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" @@ -628,6 +569,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.116", +] + [[package]] name = "darling_core" version = "0.21.3" @@ -666,6 +621,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.116", +] + [[package]] name = "darling_macro" version = "0.21.3" @@ -719,7 +685,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -1016,17 +981,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1065,6 +1019,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1252,10 +1221,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -1265,11 +1232,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1383,15 +1348,6 @@ dependencies = [ "digest", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "0.2.12" @@ -1496,7 +1452,6 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1505,21 +1460,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-named-pipe" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" -dependencies = [ - "hex", - "hyper 1.8.1", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", - "winapi", -] - [[package]] name = "hyper-rustls" version = "0.24.2" @@ -1530,26 +1470,25 @@ dependencies = [ "http 0.2.12", "hyper 0.14.32", "log", - "rustls 0.21.12", - "rustls-native-certs 0.6.3", + "rustls", + "rustls-native-certs", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls", ] [[package]] -name = "hyper-rustls" -version = "0.27.7" +name = "hyper-tls" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ - "http 1.4.0", + "bytes", + "http-body-util", "hyper 1.8.1", "hyper-util", - "rustls 0.23.36", - "rustls-native-certs 0.8.3", - "rustls-pki-types", + "native-tls", "tokio", - "tokio-rustls 0.26.4", + "tokio-native-tls", "tower-service", ] @@ -1576,21 +1515,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hyperlocal" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" -dependencies = [ - "hex", - "http-body-util", - "hyper 1.8.1", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1862,7 +1786,6 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", - "redox_syscall 0.7.1", ] [[package]] @@ -1907,12 +1830,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "matchers" version = "0.2.0" @@ -2001,6 +1918,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.6.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -2157,6 +2091,32 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -2169,6 +2129,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "outref" version = "0.5.2" @@ -2199,7 +2171,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -2296,12 +2268,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "postgres-protocol" version = "0.6.10" @@ -2376,6 +2342,28 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2400,9 +2388,9 @@ dependencies = [ [[package]] name = "progenitor-client" -version = "0.11.2" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71a0beb939758f229cbae70a4889c7c76a4ac0e90f0b1e7ae9b4636a927d1018" +checksum = "ffab7b358944dba033a7b324e7558e66e6bcb1fb4705cf57f26fd5092bcae630" dependencies = [ "bytes", "futures-core", @@ -2413,61 +2401,6 @@ dependencies = [ "serde_urlencoded", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls 0.23.36", - "socket2 0.6.2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls 0.23.36", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.44" @@ -2565,15 +2498,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_syscall" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" -dependencies = [ - "bitflags", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -2639,9 +2563,9 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", @@ -2651,22 +2575,20 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.8.1", - "hyper-rustls 0.27.7", + "hyper-tls", "hyper-util", "js-sys", "log", + "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls 0.23.36", - "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-native-tls", "tokio-util", "tower", "tower-http", @@ -2678,11 +2600,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-streams" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d2484a49257a16e13f0c5f760c6e65a231eb32fbe6f899f6caa6f9bc78e2799" +dependencies = [ + "async-trait", + "bytes", + "cargo-husky", + "futures", + "reqwest", + "serde", + "serde_json", + "tokio-util", +] + [[package]] name = "reqwest-websocket" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd5f79b25f7f17a62cc9337108974431a66ae5a723ac0d9fe78ac1cce2027720" +checksum = "7705b649c3b66b85c4e9c304a6898b1ae3eecb880c474720ebf925e4a932ae02" dependencies = [ "async-tungstenite", "bytes", @@ -2833,7 +2771,7 @@ dependencies = [ "async-trait", "base64 0.21.7", "hyper 0.14.32", - "hyper-rustls 0.24.2", + "hyper-rustls", "log", "rand 0.8.5", "rocket", @@ -2870,12 +2808,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustix" version = "1.1.3" @@ -2897,24 +2829,10 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring", - "rustls-webpki 0.101.7", + "rustls-webpki", "sct", ] -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki 0.103.9", - "subtle", - "zeroize", -] - [[package]] name = "rustls-native-certs" version = "0.6.3" @@ -2922,23 +2840,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ "openssl-probe 0.1.6", - "rustls-pemfile 1.0.4", + "rustls-pemfile", "schannel", "security-framework 2.11.1", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe 0.2.1", - "rustls-pki-types", - "schannel", - "security-framework 3.6.0", -] - [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2948,22 +2854,12 @@ dependencies = [ "base64 0.21.7", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] @@ -2977,17 +2873,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -3018,7 +2903,7 @@ dependencies = [ "chrono", "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "uuid", @@ -3026,33 +2911,35 @@ dependencies = [ [[package]] name = "schemars" -version = "0.9.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] [[package]] -name = "schemars" -version = "1.2.1" +name = "schemars_derive" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.116", ] [[package]] name = "schemars_derive" -version = "0.8.22" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -3180,6 +3067,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.13.0", "itoa", "memchr", "serde", @@ -3187,17 +3075,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -3237,15 +3114,25 @@ dependencies = [ "base64 0.22.1", "chrono", "hex", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", "schemars 1.2.1", "serde_core", "serde_json", + "serde_with_macros", "time", ] +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3517,10 +3404,39 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinirun-client" +version = "0.1.1" +source = "git+https://github.com/fa-sharp/tinirun?rev=a60644d#a60644d36cdfc338a3268402a4bcabc33085245d" +dependencies = [ + "futures", + "reqwest", + "reqwest-streams", + "serde", + "serde_json", + "thiserror", + "tinirun-models", + "validator", +] + +[[package]] +name = "tinirun-models" +version = "0.1.1" +source = "git+https://github.com/fa-sharp/tinirun?rev=a60644d#a60644d36cdfc338a3268402a4bcabc33085245d" +dependencies = [ + "regex", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "thiserror", + "validator", +] + [[package]] name = "tinistream-client" -version = "0.1.7" -source = "git+https://github.com/fa-sharp/tinistream?rev=c37e41d#c37e41dea494c82f06507387cedecb5c3737df47" +version = "0.1.10" +source = "git+https://github.com/fa-sharp/tinistream?rev=f25144c#f25144c1bdbee827d6033606b94a8aa1ae6eb5a7" dependencies = [ "bytes", "futures-core", @@ -3582,6 +3498,16 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.16" @@ -3614,17 +3540,7 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls 0.21.12", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls 0.23.36", + "rustls", "tokio", ] @@ -3852,9 +3768,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -4000,6 +3916,36 @@ dependencies = [ "vsimd", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling 0.20.11", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4158,9 +4104,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -4191,16 +4137,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "whoami" version = "2.1.1" @@ -4214,28 +4150,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.48.0" @@ -4620,16 +4534,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "yansi" version = "1.0.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index 658c70e..146984d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -6,10 +6,7 @@ publish = false [dependencies] aes-gcm = "0.10.3" -astral-tokio-tar = "0.5.6" base64 = "0.22.1" -bollard = { version = "0.19.4", features = ["ssl"] } -bon = "3.9" chrono = { version = "0.4.43", features = ["serde"] } const_format = "0.2.35" diesel = { version = "2.3.6", features = [ @@ -36,12 +33,12 @@ fred = { version = "10.1.0", default-features = false, features = [ hex = "0.4.3" jsonschema = { version = "0.30.0", default-features = false } rand = "0.9.2" -reqwest = { version = "0.12.28", default-features = false, features = [ +reqwest = { version = "0.13.2", default-features = false, features = [ "json", - "rustls-tls-native-roots", + "native-tls-no-alpn", "stream", ] } -reqwest-websocket = { version = "0.5.1", features = ["json"] } +reqwest-websocket = { version = "0.6.0", features = ["json"] } rocket = { version = "0.5.1", features = ["json", "uuid"] } rocket_flex_session = { version = "0.2.0", features = [ "redis_fred", @@ -54,7 +51,8 @@ serde = { version = "1.0.228" } serde_json = "1.0.149" subst = { version = "0.3.8", features = ["json"] } thiserror = "2.0.18" -tinistream-client = { git = "https://github.com/fa-sharp/tinistream", rev = "c37e41d" } +tinirun-client = { git = "https://github.com/fa-sharp/tinirun", rev = "a60644d" } +tinistream-client = { git = "https://github.com/fa-sharp/tinistream", rev = "f25144c" } tokio = { version = "1.49.0" } tokio-stream = "0.1.18" tokio-util = { version = "0.7.18", features = ["io"] } diff --git a/server/Rocket.toml b/server/Rocket.toml index 0cf5d9b..874f48f 100644 --- a/server/Rocket.toml +++ b/server/Rocket.toml @@ -8,3 +8,4 @@ server_address = "http://localhost:8000" database_url = "postgres://postgres:postgres@localhost/postgres" redis_url = "redis://localhost:6379" tinistream_url = "http://localhost:8081" +tinirun_url = "http://localhost:8082/api" diff --git a/server/src/api/chat.rs b/server/src/api/chat.rs index 33e8332..5722fe3 100644 --- a/server/src/api/chat.rs +++ b/server/src/api/chat.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use rocket::{futures::StreamExt, get, post, serde::json::Json, Route, State}; +use rocket::{get, post, serde::json::Json, Route, State}; use rocket_okapi::{ okapi::openapi3::OpenApi, openapi, openapi_get_routes_spec, settings::OpenApiSettings, }; @@ -151,7 +151,7 @@ pub async fn send_chat_stream( &session_id, &user_message, &provider_api, - &input.options.model, + &provider.default_model, db_pool, ); } @@ -169,50 +169,24 @@ pub async fn send_chat_stream( messages.push(message); } - // Convert the messages, and get the provider's response + // Build the messages and get the initial stream response from the provider let llm_messages = build_llm_messages(messages, &user_id, &session_id, &mut db, &storage).await?; let stream = provider_api .chat_stream(llm_messages, tools, &input.options) .await?; - // Create the Redis stream and get a WebSocket connection for writing to it - let stream_access = tinistream.stream_start(&stream_key).await?; - let (ws_writer, ws_reader) = tinistream.stream_writer_ws(&stream_key).await?.split(); - - // Spawn a task to stream and save the response - let tinistream = tinistream.inner().to_owned(); - let provider_id = input.provider_id.clone(); - let provider_options = input.options.clone(); - tokio::spawn(async move { - let mut stream_writer = LlmStreamWriter::new(); - let (text, tool_calls, usage, errors, cancelled) = - stream_writer.process(stream, ws_writer, ws_reader).await; - - let assistant_meta = AssistantMeta { - provider_id, - provider_options: Some(provider_options), - tool_calls, - usage, - errors, - partial: cancelled.then_some(true), - }; - let db_result = ChatDbService::new(&mut db) - .save_message(NewChatRsMessage { - session_id: &session_id, - role: ChatRsMessageRole::Assistant, - content: &text.unwrap_or_default(), - meta: ChatRsMessageMeta::new_assistant(assistant_meta), - }) - .await; - if let Err(err) = db_result { - rocket::error!("Failed to save assistant message: {}", err); - } - - if !cancelled { - tinistream.stream_end(&stream_key).await.ok(); - } - }); + // Start the client stream and get the access URL / token + let stream_access = LlmClientStreamer::new(db, tinistream, storage) + .start( + stream, + stream_key, + user_id.clone(), + session_id, + input.provider_id, + input.into_inner().options, + ) + .await?; Ok(Json(StreamAccess { url: stream_access.sse_url, @@ -230,11 +204,11 @@ pub async fn connect_to_chat_stream( tinistream: &State, ) -> Result, ApiError> { let key = chat_stream_key(&user_id, &session_id); - let connect = tinistream.stream_connect(&key).await?; + let stream_access = tinistream.stream_connect(&key).await?; Ok(Json(StreamAccess { - url: connect.sse_url, - token: connect.token, + url: stream_access.sse_url, + token: stream_access.token, })) } diff --git a/server/src/api/tool.rs b/server/src/api/tool.rs index e2053b3..4265800 100644 --- a/server/src/api/tool.rs +++ b/server/src/api/tool.rs @@ -214,7 +214,7 @@ async fn execute_tool( let tool_result = match (system_tool, external_api_tool) { (Some(system_tool), None) => { system_tool - .build_executor(&mut db, &app_config, &message.session_id) + .build_executor(&mut db, &app_config, &http_client, &message.session_id) .validate_and_execute( &tool_call.tool_name, &tool_call.parameters, diff --git a/server/src/auth/sso_header.rs b/server/src/auth/sso_header.rs index 5df4b1f..ebe31dc 100644 --- a/server/src/auth/sso_header.rs +++ b/server/src/auth/sso_header.rs @@ -17,25 +17,34 @@ struct SsoHeaderConfig { /// Whether SSO header authentication is enabled sso_header_enabled: bool, /// Header for unique, identifying username (default: `Remote-User`) - sso_username_header: Option, + #[serde(default = "default_username_header")] + sso_username_header: String, /// Header for display name (default: `Remote-Name`) - sso_name_header: Option, + #[serde(default = "default_name_header")] + sso_name_header: String, /// Header for groups the user belongs to (default: `Remote-Groups`) - sso_groups_header: Option, + #[serde(default = "default_groups_header")] + sso_groups_header: String, /// If set, only users in this group will be allowed to access the app sso_user_group: Option, /// URL to redirect to in order to log out of the remote service sso_logout_url: Option, } +fn default_username_header() -> String { + "Remote-User".to_string() +} +fn default_name_header() -> String { + "Remote-Name".to_string() +} +fn default_groups_header() -> String { + "Remote-Groups".to_string() +} /// SSO header config added to Rocket state when enabled -#[derive(bon::Builder, Debug, Deserialize)] +#[derive(Debug, Deserialize)] pub struct SsoHeaderMergedConfig { - #[builder(into, default = "Remote-User")] pub username_header: String, - #[builder(into, default = "Remote-Name")] pub name_header: String, - #[builder(into, default = "Remote-Groups")] pub groups_header: String, pub user_group: Option, pub logout_url: Option, @@ -54,13 +63,13 @@ pub fn setup_sso_header_auth() -> AdHoc { match get_config_provider().extract::() { Ok(config) => { if config.sso_header_enabled { - let merged_config = SsoHeaderMergedConfig::builder() - .maybe_username_header(config.sso_username_header) - .maybe_name_header(config.sso_name_header) - .maybe_groups_header(config.sso_groups_header) - .maybe_user_group(config.sso_user_group) - .maybe_logout_url(config.sso_logout_url) - .build(); + let merged_config = SsoHeaderMergedConfig { + username_header: config.sso_username_header, + name_header: config.sso_name_header, + groups_header: config.sso_groups_header, + user_group: config.sso_user_group, + logout_url: config.sso_logout_url, + }; rocket::info!("SSO header auth: enabled! Config: {:?}", merged_config); rocket.manage(merged_config) } else { diff --git a/server/src/config.rs b/server/src/config.rs index 48df888..9a62bcf 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -18,16 +18,23 @@ pub struct AppConfig { pub static_path: Option, /// Local data directory (default: "/data") pub data_dir: Option, + /// Postgres Database URL pub database_url: String, /// Redis connection URL pub redis_url: String, /// Redis pool size (default: 4) pub redis_pool: Option, + /// Base URL of the tinistream API pub tinistream_url: String, /// API key for the tinistream API pub tinistream_api_key: String, + + /// Base URL of the tinirun API + pub tinirun_url: String, + /// API key for the tinirun API + pub tinirun_api_key: String, } /// Get the server configuration variables from Rocket diff --git a/server/src/db.rs b/server/src/db.rs index 32f9e14..4bc7089 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -55,7 +55,7 @@ impl<'r> FromRequest<'r> for DbConnection { Ok(conn) => Outcome::Success(DbConnection(conn)), Err(e) => { rocket::error!("Couldn't get database connection: {e}"); - Outcome::Error((Status::InternalServerError, "Couldn't get connection")) + Outcome::Error((Status::InternalServerError, "Couldn't get db connection")) } } } @@ -64,24 +64,24 @@ impl<'r> FromRequest<'r> for DbConnection { /// Fairing that sets up and initializes the Postgres database pub fn setup_db() -> AdHoc { AdHoc::on_ignite("Database", |rocket| async { - let config = AsyncDieselConnectionManager::::new( - &get_app_config(&rocket).database_url, - ); + let db_url = get_app_config(&rocket).database_url.as_str(); + let config = AsyncDieselConnectionManager::::new(db_url); let pool: DbPool = Pool::builder(config) .build() .expect("Failed to parse database URL"); const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); - let cxn = pool.get().await.expect("Failed to connect to database"); - tokio::task::spawn_blocking(move || { - AsyncConnectionWrapper::>::from(cxn) + let migration_cxn = pool.get().await.expect("Failed to connect to database"); + match tokio::task::spawn_blocking(move || { + AsyncConnectionWrapper::>::from(migration_cxn) .run_pending_migrations(MIGRATIONS) .expect("Database migrations failed"); }) .await - .expect("Database migration task failed"); - - rocket::info!("Migrations completed successfully"); + { + Ok(_) => rocket::info!("Migrations completed successfully"), + Err(err) => panic!("Database migration task failed: {err}"), + }; let shutdown = AdHoc::on_shutdown("Shutdown database", |rocket| { Box::pin(async { diff --git a/server/src/db/models/chat.rs b/server/src/db/models/chat.rs index 1b27574..371c21f 100644 --- a/server/src/db/models/chat.rs +++ b/server/src/db/models/chat.rs @@ -116,6 +116,9 @@ pub struct AssistantMeta { /// The tool calls requested by the assistant #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, + /// IDs of generated files + #[serde(skip_serializing_if = "Option::is_none")] + pub files: Option>, /// Provider usage information #[serde(skip_serializing_if = "Option::is_none")] pub usage: Option, diff --git a/server/src/db/models/provider.rs b/server/src/db/models/provider.rs index 4dda182..f7dd3e2 100644 --- a/server/src/db/models/provider.rs +++ b/server/src/db/models/provider.rs @@ -14,6 +14,8 @@ pub struct ChatRsProvider { pub name: String, #[schemars(with = "ChatRsProviderType")] pub provider_type: String, + // #[schemars(with = "OpenaiSubtype")] + // pub openai_subtype: Option, #[serde(skip)] pub user_id: Uuid, pub default_model: String, @@ -52,6 +54,16 @@ pub enum ChatRsProviderType { Lorem, } +/// The subtype for OpenAI-compatible providers +#[derive(JsonSchema, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OpenaiSubtype { + Openai, + Google, + OpenRouter, + LlmGateway, +} + impl TryFrom<&str> for ChatRsProviderType { type Error = LlmError; diff --git a/server/src/db/services/chat.rs b/server/src/db/services/chat.rs index 5fdb0b8..360373d 100644 --- a/server/src/db/services/chat.rs +++ b/server/src/db/services/chat.rs @@ -1,5 +1,6 @@ use diesel::prelude::*; use diesel_async::RunQueryDsl; +use rocket::futures; use uuid::Uuid; use crate::{ @@ -121,17 +122,19 @@ impl<'a> ChatDbService<'a> { user_id: &Uuid, session_id: &Uuid, ) -> Result<(ChatRsSession, Vec), diesel::result::Error> { - let session = chat_sessions::table - .filter(chat_sessions::user_id.eq(user_id)) - .filter(chat_sessions::id.eq(session_id)) - .select(ChatRsSession::as_select()) - .first(self.db) - .await?; - let messages = ChatRsMessage::belonging_to(&session) - .select(ChatRsMessage::as_select()) - .order_by(chat_messages::created_at.asc()) - .load(self.db) - .await?; + let (session, messages) = futures::future::try_join( + chat_sessions::table + .filter(chat_sessions::user_id.eq(user_id)) + .filter(chat_sessions::id.eq(session_id)) + .select(ChatRsSession::as_select()) + .first(self.db), + chat_messages::table + .filter(chat_messages::session_id.eq(session_id)) + .select(ChatRsMessage::as_select()) + .order_by(chat_messages::created_at.asc()) + .load(self.db), + ) + .await?; Ok((session, messages)) } diff --git a/server/src/db/services/tool.rs b/server/src/db/services/tool.rs index 98eee3e..4533eb5 100644 --- a/server/src/db/services/tool.rs +++ b/server/src/db/services/tool.rs @@ -1,6 +1,7 @@ use diesel::prelude::*; use diesel::result::Error; use diesel_async::RunQueryDsl; +use rocket::futures; use uuid::Uuid; use crate::db::{ @@ -25,16 +26,17 @@ impl<'a> ToolDbService<'a> { &mut self, user_id: &Uuid, ) -> Result<(Vec, Vec), Error> { - let system_tools = system_tools::table - .filter(system_tools::user_id.eq(user_id)) - .select(ChatRsSystemTool::as_select()) - .load(self.db) - .await?; - let external_api_tools = external_api_tools::table - .filter(external_api_tools::user_id.eq(user_id)) - .select(ChatRsExternalApiTool::as_select()) - .load(self.db) - .await?; + let (system_tools, external_api_tools) = futures::future::try_join( + system_tools::table + .filter(system_tools::user_id.eq(user_id)) + .select(ChatRsSystemTool::as_select()) + .load(self.db), + external_api_tools::table + .filter(external_api_tools::user_id.eq(user_id)) + .select(ChatRsExternalApiTool::as_select()) + .load(self.db), + ) + .await?; Ok((system_tools, external_api_tools)) } diff --git a/server/src/provider/core.rs b/server/src/provider/core.rs index afbec27..332bda9 100644 --- a/server/src/provider/core.rs +++ b/server/src/provider/core.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::{ db::models::{ChatRsFileType, ChatRsToolCall}, - provider::models::LlmModel, + provider::models::{LlmModel, ModalityType}, }; /// Unified API for LLM providers @@ -42,6 +42,7 @@ pub enum LlmStreamChunk { Text(String), ToolCalls(Vec), PendingToolCall(LlmPendingToolCall), + Images(Vec), Usage(LlmUsage), } @@ -103,6 +104,12 @@ pub struct LlmPendingToolCall { pub tool_name: String, } +/// A generated image from the LLM provider +#[derive(Debug, Clone)] +pub struct LlmImage { + pub base64_url: String, +} + /// Usage stats from the LLM provider #[derive(Debug, Default, JsonSchema, serde::Serialize, serde::Deserialize)] pub struct LlmUsage { @@ -113,12 +120,25 @@ pub struct LlmUsage { pub cost: Option, } +/// Complete processed response from the LLM provider +pub struct LlmOutput { + pub text: Option, + pub tool_calls: Option>, + pub images: Option>, + pub usage: Option, + pub errors: Option>, + pub cancelled: bool, +} + /// Configuration for LLM provider requests #[derive(Clone, Debug, Default, JsonSchema, serde::Serialize, serde::Deserialize)] pub struct LlmProviderOptions { pub model: String, pub temperature: Option, pub max_tokens: Option, + /// Only supported for OpenRouter + #[serde(skip_serializing_if = "Option::is_none")] + pub modalities: Option>, } /// Generic message type to send to LLM providers diff --git a/server/src/provider/providers/openai.rs b/server/src/provider/providers/openai.rs index edf2c06..cb7373b 100644 --- a/server/src/provider/providers/openai.rs +++ b/server/src/provider/providers/openai.rs @@ -50,12 +50,15 @@ impl LlmApiProvider for OpenAIProvider { let request = OpenAIRequest { model: &options.model, messages: openai_messages, - max_tokens: (options.max_tokens.is_some() && self.base_url != OPENAI_API_BASE_URL) - .then(|| options.max_tokens.expect("already checked for Some value")), // OpenAI official API has deprecated `max_tokens` for `max_completion_tokens` - max_completion_tokens: (options.max_tokens.is_some() - && self.base_url == OPENAI_API_BASE_URL) - .then(|| options.max_tokens.expect("already checked for Some value")), + max_tokens: match options.max_tokens { + Some(max_tokens) if self.base_url != OPENAI_API_BASE_URL => Some(max_tokens), + _ => None, + }, + max_completion_tokens: match options.max_tokens { + Some(max_tokens) if self.base_url == OPENAI_API_BASE_URL => Some(max_tokens), + _ => None, + }, temperature: options.temperature, store: (self.base_url == OPENAI_API_BASE_URL).then_some(false), stream: Some(true), @@ -63,6 +66,7 @@ impl LlmApiProvider for OpenAIProvider { include_usage: true, }), tools: openai_tools, + modalities: options.modalities.as_ref(), }; let response = self diff --git a/server/src/provider/providers/openai/request.rs b/server/src/provider/providers/openai/request.rs index 87457d3..6e2b32d 100644 --- a/server/src/provider/providers/openai/request.rs +++ b/server/src/provider/providers/openai/request.rs @@ -2,7 +2,7 @@ use serde::Serialize; use crate::{ db::models::ChatRsFileType, - provider::{utils::create_data_uri, LlmMessage, LlmTool}, + provider::{models::ModalityType, utils::create_data_uri, LlmMessage, LlmTool}, }; pub fn build_openai_messages<'a>(messages: &'a [LlmMessage]) -> Vec> { @@ -115,6 +115,8 @@ pub struct OpenAIRequest<'a> { pub stream_options: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub modalities: Option<&'a Vec>, } /// OpenAI API request stream options diff --git a/server/src/provider/providers/openai/response.rs b/server/src/provider/providers/openai/response.rs index 534d45d..bad7fd7 100644 --- a/server/src/provider/providers/openai/response.rs +++ b/server/src/provider/providers/openai/response.rs @@ -2,7 +2,9 @@ use serde::Deserialize; use crate::{ db::models::ChatRsToolCall, - provider::{LlmPendingToolCall, LlmStreamChunk, LlmStreamChunkResult, LlmTool, LlmUsage}, + provider::{ + LlmImage, LlmPendingToolCall, LlmStreamChunk, LlmStreamChunkResult, LlmTool, LlmUsage, + }, }; /// Parse chunks from an OpenAI SSE event @@ -43,6 +45,16 @@ pub fn parse_openai_event( } } } + if let Some(images) = delta.images { + chunks.push(Ok(LlmStreamChunk::Images( + images + .into_iter() + .map(|image| LlmImage { + base64_url: image.image_url.url, + }) + .collect(), + ))); + } } if let Some(usage) = event.usage { chunks.push(Ok(LlmStreamChunk::Usage(usage.into()))); @@ -86,6 +98,9 @@ pub struct OpenAIResponseDelta { // role: Option, content: Option, tool_calls: Option>, + /// OpenRouter images + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>, } /// OpenAI streaming tool call @@ -122,13 +137,30 @@ struct OpenAIStreamToolCallFunction { arguments: Option, } +/// OpenRouter image +#[derive(Debug, Deserialize)] +pub struct OpenRouterImage { + // #[serde(rename = "type")] + // pub image_type: String, + pub image_url: OpenRouterImageData, +} + +/// OpenRouter image data +#[derive(Debug, Deserialize)] +pub struct OpenRouterImageData { + /// Base64 data URL + pub url: String, +} + /// OpenAI API response usage #[derive(Debug, Deserialize)] pub struct OpenAIUsage { prompt_tokens: Option, completion_tokens: Option, + /// OpenRouter cost cost: Option, - // total_tokens: Option, + /// LLM Gateway cost + cost_usd_total: Option, } impl From for LlmUsage { @@ -136,7 +168,7 @@ impl From for LlmUsage { LlmUsage { input_tokens: usage.prompt_tokens, output_tokens: usage.completion_tokens, - cost: usage.cost, + cost: usage.cost.or(usage.cost_usd_total), } } } diff --git a/server/src/provider/utils.rs b/server/src/provider/utils.rs index 713a66c..0f18a5b 100644 --- a/server/src/provider/utils.rs +++ b/server/src/provider/utils.rs @@ -12,7 +12,7 @@ use crate::provider::LlmStreamError; /// Create a data URI pub fn create_data_uri(content_type: &str, b64_string: &str) -> String { - format!("data:{};base64,{}", content_type, b64_string) + format!("data:{content_type};base64,{b64_string}") } /// Get a stream of deserialized events from a provider SSE stream. diff --git a/server/src/storage/local.rs b/server/src/storage/local.rs index 4c0cd9f..3913806 100644 --- a/server/src/storage/local.rs +++ b/server/src/storage/local.rs @@ -1,5 +1,5 @@ use std::{ - io::Result as IoResult, + io::{Result as IoResult, Write}, path::{Path, PathBuf}, }; use tokio::{ @@ -8,6 +8,8 @@ use tokio::{ }; use uuid::Uuid; +/// Local file storage +#[derive(Debug, Clone)] pub struct LocalStorage { base_path: PathBuf, } @@ -72,6 +74,20 @@ impl LocalStorage { Ok(total_bytes_written) } + pub async fn create_file_from_data_url( + &self, + user_id: &Uuid, + session_id: Option<&Uuid>, + path: &str, + data_url: String, + ) -> IoResult<(String, u64)> { + let file_path = self.get_file_path(user_id, session_id, path)?; + let dir = file_path.parent().expect("Should have a parent directory"); + tokio::fs::create_dir_all(&dir).await?; + + tokio::task::spawn_blocking(move || save_base64_url(&data_url, &file_path)).await? + } + pub async fn delete_file>( &self, user_id: &Uuid, @@ -114,7 +130,6 @@ impl LocalStorage { } /// Synchronously read a file as a base64 encoded string. -/// (This is synchronous because the `base64` crate is synchronous.) fn read_base64(path: &Path) -> IoResult { let mut file = std::fs::File::open(path)?; let file_size = file.metadata()?.len(); @@ -132,3 +147,24 @@ fn read_base64(path: &Path) -> IoResult { } Ok(String::from_utf8(result).expect("base64 is valid UTF8")) } + +/// Synchronously save a base64 data URL to a file. Returns the content type and size of the saved file. +fn save_base64_url(data_url: &str, output_path: &Path) -> IoResult<(String, u64)> { + let (prefix, base64_data) = data_url + .split_once(',') + .ok_or(std::io::Error::other("Invalid data URL format"))?; + let content_type = prefix + .strip_prefix("data:") + .and_then(|p| p.strip_suffix(";base64")) + .ok_or(std::io::Error::other("Invalid data URL prefix"))?; + + let mut decoder = base64::read::DecoderReader::new( + std::io::Cursor::new(base64_data.as_bytes()), + &base64::engine::general_purpose::STANDARD, + ); + let mut writer = std::io::BufWriter::new(std::fs::File::create(output_path)?); + let total_bytes = std::io::copy(&mut decoder, &mut writer)?; + writer.flush()?; + + Ok((content_type.to_owned(), total_bytes)) +} diff --git a/server/src/stream.rs b/server/src/stream.rs index dc6ed00..0c31b3c 100644 --- a/server/src/stream.rs +++ b/server/src/stream.rs @@ -1,9 +1,11 @@ #[cfg(test)] mod tests; +mod streamer; mod tinistream; mod writer; +pub use streamer::*; pub use tinistream::*; pub use writer::*; diff --git a/server/src/stream/streamer.rs b/server/src/stream/streamer.rs new file mode 100644 index 0000000..49981b8 --- /dev/null +++ b/server/src/stream/streamer.rs @@ -0,0 +1,114 @@ +use rocket::futures::StreamExt; +use tinistream_client::types::StreamAccessResponse; +use uuid::Uuid; + +use crate::{ + db::{ + models::{ + AssistantMeta, ChatRsFileType, ChatRsMessageMeta, ChatRsMessageRole, NewChatRsFile, + NewChatRsMessage, + }, + services::{ChatDbService, FileDbService}, + DbConnection, + }, + errors::ApiError, + provider::{LlmProviderOptions, LlmStream}, + storage::LocalStorage, + stream::TinistreamClient, +}; + +/// Utility that handles streaming to clients and persisting responses from the provider +pub struct LlmClientStreamer { + db: DbConnection, + tinistream: TinistreamClient, + storage: LocalStorage, +} + +impl LlmClientStreamer { + pub fn new(db: DbConnection, tinistream: &TinistreamClient, storage: &LocalStorage) -> Self { + Self { + db, + tinistream: tinistream.to_owned(), + storage: storage.to_owned(), + } + } + + pub async fn start( + mut self, + stream: LlmStream, + stream_key: String, + user_id: Uuid, + session_id: Uuid, + provider_id: i32, + provider_options: LlmProviderOptions, + ) -> Result { + // Create the Redis stream in `tinistream` and get a WebSocket connection for writing to it + let stream_access = self.tinistream.stream_start(&stream_key).await?; + let (ws_writer, ws_reader) = self.tinistream.stream_writer_ws(&stream_key).await?.split(); + + // Spawn a task to finish streaming and process/save the response + tokio::spawn(async move { + let response = super::LlmStreamWriter::new() + .process(stream, ws_writer, ws_reader) + .await; + + // Save generated images + let mut image_ids: Option> = None; + for image in response.images.unwrap_or_default() { + let path = format!("generated/{}.png", Uuid::new_v4()); + match self + .storage + .create_file_from_data_url(&user_id, Some(&session_id), &path, image.base64_url) + .await + { + Ok((content_type, size)) => { + match FileDbService::new(&mut self.db) + .create_session_file(NewChatRsFile { + user_id: &user_id, + session_id: Some(&session_id), + path: &path, + file_type: ChatRsFileType::Image.into(), + content_type: &content_type, + size: size.try_into().unwrap_or_default(), + }) + .await + { + Ok(file) => image_ids.get_or_insert_default().push(file.id), + Err(err) => rocket::error!("Failed to save image to db: {err}"), + } + } + Err(err) => rocket::error!("Failed to save image to storage: {err}"), + } + } + + // Save response message and metadata + let assistant_meta = AssistantMeta { + provider_id, + provider_options: Some(provider_options), + tool_calls: response.tool_calls, + files: image_ids, + usage: response.usage, + errors: response.errors, + partial: response.cancelled.then_some(true), + }; + if let Err(err) = ChatDbService::new(&mut self.db) + .save_message(NewChatRsMessage { + session_id: &session_id, + role: ChatRsMessageRole::Assistant, + content: &response.text.unwrap_or_default(), + meta: ChatRsMessageMeta::new_assistant(assistant_meta), + }) + .await + { + rocket::error!("Failed to save assistant message: {err}"); + } + + // Signal end of stream + if !response.cancelled { + self.tinistream.stream_end(&stream_key).await.ok(); + } + }); + + Ok(stream_access) + } +} diff --git a/server/src/stream/tests/mod.rs b/server/src/stream/tests/mod.rs index 41948e7..817b13a 100644 --- a/server/src/stream/tests/mod.rs +++ b/server/src/stream/tests/mod.rs @@ -7,8 +7,8 @@ use uuid::Uuid; use crate::{ provider::{ - providers::LoremProvider, LlmApiProvider, LlmProviderOptions, LlmStream, LlmStreamChunk, - LlmStreamError, LlmUsage, + providers::LoremProvider, LlmApiProvider, LlmOutput, LlmProviderOptions, LlmStream, + LlmStreamChunk, LlmStreamError, LlmUsage, }, stream::chat_stream_key, }; @@ -49,8 +49,14 @@ async fn stream_writer_basic_functionality() { .expect("Failed to create lorem stream"); // Process the stream - let (text, tool_calls, usage, errors, cancelled) = - writer.process(stream, ws_writer, ws_reader).await; + let LlmOutput { + text, + tool_calls, + usage, + errors, + cancelled, + .. + } = writer.process(stream, ws_writer, ws_reader).await; // Verify results assert!(text.is_some()); @@ -90,7 +96,9 @@ async fn stream_writer_batching() { ); let stream: LlmStream = Box::pin(chunk_stream); - let (text, _, _, _, cancelled) = writer.process(stream, ws_writer, ws_reader).await; + let LlmOutput { + text, cancelled, .. + } = writer.process(stream, ws_writer, ws_reader).await; assert!(text.is_some()); let text = text.unwrap(); @@ -116,7 +124,12 @@ async fn stream_writer_error_handling() { ]); let stream: LlmStream = Box::pin(error_stream); - let (text, _, _, errors, cancelled) = writer.process(stream, ws_writer, ws_reader).await; + let LlmOutput { + text, + errors, + cancelled, + .. + } = writer.process(stream, ws_writer, ws_reader).await; assert!(text.is_some()); let text = text.unwrap(); @@ -153,7 +166,9 @@ async fn stream_writer_cancel() { tini.stream_cancel(&key).await.unwrap(); // process() response should show that stream was cancelled - let (_, _, _, errors, cancelled) = process_fut.await; + let LlmOutput { + errors, cancelled, .. + } = process_fut.await; assert!(cancelled); assert!(errors.unwrap().last().unwrap().contains("cancelled")); @@ -188,7 +203,12 @@ async fn stream_writer_usage_tracking() { ]); let stream: LlmStream = Box::pin(usage_stream); - let (text, _, usage, _, cancelled) = writer.process(stream, ws_writer, ws_reader).await; + let LlmOutput { + text, + usage, + cancelled, + .. + } = writer.process(stream, ws_writer, ws_reader).await; assert!(text.is_some()); assert_eq!(text.unwrap(), "Hello World"); diff --git a/server/src/stream/tinistream.rs b/server/src/stream/tinistream.rs index 79700b9..5f10b98 100644 --- a/server/src/stream/tinistream.rs +++ b/server/src/stream/tinistream.rs @@ -1,4 +1,4 @@ -use reqwest_websocket::{RequestBuilderExt, WebSocket}; +use reqwest_websocket::{Upgrade, WebSocket}; use tinistream_client::{types::*, Client, ClientEventsExt, ClientInfo, ClientStreamExt, Error}; /// A client for interacting with the tinistream API. @@ -115,7 +115,7 @@ impl TinistreamClient { Ok(res.into_inner().status) } - /// End a stream + /// Signal the end of a stream pub async fn stream_end(&self, key: &str) -> TiniResult { let res = self .client diff --git a/server/src/stream/writer.rs b/server/src/stream/writer.rs index 50fa097..8fb0da5 100644 --- a/server/src/stream/writer.rs +++ b/server/src/stream/writer.rs @@ -10,7 +10,10 @@ use tokio_util::sync::CancellationToken; use crate::{ db::models::ChatRsToolCall, - provider::{LlmPendingToolCall, LlmStream, LlmStreamChunk, LlmStreamError, LlmUsage}, + provider::{ + LlmImage, LlmOutput, LlmPendingToolCall, LlmStream, LlmStreamChunk, LlmStreamError, + LlmUsage, + }, }; /// Interval at which chunks are flushed to the Redis stream. @@ -18,7 +21,7 @@ const FLUSH_INTERVAL: Duration = Duration::from_millis(400); /// Max # of characters of the text chunk before it is automatically flushed to Redis. const MAX_CHUNK_SIZE: usize = 200; -/// Utility for processing an incoming LLM response stream and writing to a Redis stream. +/// Utility for processing an incoming LLM response stream and writing chunks to `tinistream`. #[derive(Debug)] pub struct LlmStreamWriter { /// The current chunk of data being processed. @@ -27,6 +30,8 @@ pub struct LlmStreamWriter { complete_text: Option, /// Accumulated tool calls from the assistant. tool_calls: Option>, + /// Accumulated generated images from the assistant. + images: Option>, /// Accumulated errors during the stream from the LLM provider. errors: Option>, /// Accumulated usage information from the LLM provider. @@ -58,26 +63,21 @@ impl LlmStreamWriter { current_chunk: ChunkState::default(), complete_text: None, tool_calls: None, + images: None, errors: None, usage: None, } } /// Process the incoming stream from the LLM provider, intermittently flushing - /// chunks to tinistream via the WebSocket connection, and return the final + /// chunks to `tinistream` via the WebSocket connection, and return the final /// accumulated response. pub async fn process( &mut self, stream: LlmStream, mut ws_writer: SplitSink, mut ws_reader: SplitStream, - ) -> ( - Option, - Option>, - Option, - Option>, - bool, - ) { + ) -> LlmOutput { let mut cancelled = false; // Spawn task to listen for stream cancellation @@ -102,15 +102,18 @@ impl LlmStreamWriter { cancel_task.abort(); ws_writer.close().await.ok(); - let complete_text = self.complete_text.take(); - let tool_calls = self.tool_calls.take(); - let usage = self.usage.take(); - let errors = self.errors.take().map(|e| { - e.into_iter() - .map(|e| e.to_string()) - .collect::>() - }); - (complete_text, tool_calls, usage, errors, cancelled) + LlmOutput { + text: self.complete_text.take(), + tool_calls: self.tool_calls.take(), + images: self.images.take(), + usage: self.usage.take(), + errors: self.errors.take().map(|e| { + e.into_iter() + .map(|e| e.to_string()) + .collect::>() + }), + cancelled, + } } async fn process_stream( @@ -127,6 +130,7 @@ impl LlmStreamWriter { LlmStreamChunk::PendingToolCall(pending_tool_call) => { self.process_pending_tool_call(pending_tool_call) } + LlmStreamChunk::Images(images) => self.process_images(images), LlmStreamChunk::Usage(usage) => self.process_usage(usage), }, Some(Err(err)) => self.process_error(err), @@ -174,6 +178,10 @@ impl LlmStreamWriter { } } + fn process_images(&mut self, images: Vec) { + self.images.get_or_insert_default().extend(images); + } + fn process_usage(&mut self, usage_chunk: LlmUsage) { let usage = self.usage.get_or_insert_default(); if let Some(input_tokens) = usage_chunk.input_tokens { diff --git a/server/src/tools/system.rs b/server/src/tools/system.rs index b197666..f9aacd0 100644 --- a/server/src/tools/system.rs +++ b/server/src/tools/system.rs @@ -137,11 +137,17 @@ impl<'a> ChatRsSystemTool { &'a self, db: &'a mut DbConnection, app_config: &'a AppConfig, + http_client: &'a reqwest::Client, session_id: &'a Uuid, ) -> Box { match &self.data { ChatRsSystemToolConfig::CodeRunner(config) => { - Box::new(code_runner::CodeRunner::new(config)) + let client = tinirun_client::TinirunClient::with_client( + http_client.to_owned(), + app_config.tinirun_url.clone(), + app_config.tinirun_api_key.clone(), + ); + Box::new(code_runner::CodeRunner::new(client, config)) } ChatRsSystemToolConfig::SystemInfo => { Box::new(system_info::SystemInfo::new(app_config)) diff --git a/server/src/tools/system/code_runner.rs b/server/src/tools/system/code_runner.rs index 5600587..0abf2b7 100644 --- a/server/src/tools/system/code_runner.rs +++ b/server/src/tools/system/code_runner.rs @@ -1,7 +1,3 @@ -mod docker; -mod dockerfiles; -use docker::{DockerExecutor, DockerExecutorOptions}; - use std::sync::LazyLock; use rocket::async_trait; @@ -20,14 +16,20 @@ use crate::{ utils::SenderWithLogging, }; +mod tinirun; +use tinirun::{TinirunExecutor, TinirunExecutorOptions}; + const CODE_RUNNER_NAME: &str = "code_runner"; const CODE_RUNNER_DESCRIPTION: &str = "Run code snippet in a sandboxed environment. \ - Temporary files can be written to the `$HOME` directory (must be created first). \ + Temporary files can be written to the /tmp directory and subdirectories. \ Other than that, it is a read-only environment."; const DEFAULT_TIMEOUT_SECONDS: u32 = 30; const DEFAULT_MEMORY_LIMIT_MB: u32 = 512; const DEFAULT_CPU_LIMIT: f32 = 0.5; +static CODE_RUNNER_INPUT_SCHEMA: LazyLock = + LazyLock::new(|| get_json_schema::()); + #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] struct CodeRunnerInput { @@ -42,25 +44,22 @@ struct CodeRunnerInput { dependencies: Vec, // /// Whether to enable network access. Set to `true` only if the program needs to access the internet at runtime. // /// Network access is not needed for downloading dependencies. - // /// TODO: needs more safety precautions + // /// TODO: disabled for now because tinirun doesn't support this (security risk) // network: bool, } -static CODE_RUNNER_INPUT_SCHEMA: LazyLock = - LazyLock::new(|| get_json_schema::()); - -/// Tool to run code snippets in a sandboxed environment. -#[derive(Debug)] +/// Tool to run code snippets in a sandboxed environment. Uses the `tinirun` service. pub struct CodeRunner<'a> { + client: tinirun_client::TinirunClient, config: &'a CodeRunnerConfig, } impl<'a> CodeRunner<'a> { - pub fn new(config: &'a CodeRunnerConfig) -> Self { - CodeRunner { config } + pub fn new(client: tinirun_client::TinirunClient, config: &'a CodeRunnerConfig) -> Self { + CodeRunner { client, config } } } -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] enum CodeLanguage { Python, @@ -130,19 +129,19 @@ impl SystemTool for CodeRunner<'_> { sender: &SenderWithLogging, ) -> ToolResult<(String, ToolResponseFormat)> { let input = serde_json::from_value::(params)?; - let executor = DockerExecutor::new( + let executor = TinirunExecutor::new( + &self.client, input.language, - DockerExecutorOptions { + TinirunExecutorOptions { timeout_seconds: self.config.timeout_seconds, memory_limit_mb: self.config.memory_limit_mb, cpu_limit: self.config.cpu_limit, - network: false, }, ); - let tool_response = executor .execute(&input.code, &input.dependencies, sender) .await?; + Ok((tool_response, ToolResponseFormat::Markdown)) } } diff --git a/server/src/tools/system/code_runner/docker.rs b/server/src/tools/system/code_runner/docker.rs deleted file mode 100644 index 05dab29..0000000 --- a/server/src/tools/system/code_runner/docker.rs +++ /dev/null @@ -1,435 +0,0 @@ -use std::{sync::LazyLock, time::Duration}; - -use bollard::{ - body_try_stream, - container::{AttachContainerResults, LogOutput}, - models::{ContainerCreateBody, HostConfig, ResourcesUlimits}, - query_parameters::*, - Docker, -}; -use rocket::futures::StreamExt; -use tokio_util::io::ReaderStream; -use uuid::Uuid; - -use crate::{ - tools::{ - core::{ToolLog, ToolResult}, - system::code_runner::{ - dockerfiles::{get_dockerfile, get_dockerfile_info}, - CodeLanguage, - }, - ToolError, - }, - utils::SenderWithLogging, -}; - -static DOCKER: LazyLock> = - LazyLock::new(|| Docker::connect_with_defaults()); - -const GRACE_PERIOD_SECONDS: u32 = 5; - -pub struct DockerExecutor { - lang: CodeLanguage, - timeout_seconds: u32, - memory_limit_mb: u32, - cpu_limit: f32, - network: bool, - image_tag: String, - container_name: String, -} - -#[derive(Debug, Default)] -pub struct DockerExecutorOptions { - pub timeout_seconds: u32, - pub memory_limit_mb: u32, - pub cpu_limit: f32, - pub network: bool, -} - -impl DockerExecutor { - pub fn new(lang: CodeLanguage, options: DockerExecutorOptions) -> Self { - DockerExecutor { - lang, - timeout_seconds: options.timeout_seconds, - memory_limit_mb: options.memory_limit_mb, - cpu_limit: options.cpu_limit, - network: options.network, - image_tag: format!("code-runner-{}", Uuid::new_v4()), - container_name: format!("code-runner-{}", Uuid::new_v4()), - } - } - - pub async fn execute( - &self, - code: &str, - dependencies: &[String], - tx: &SenderWithLogging, - ) -> ToolResult { - let docker = DOCKER - .as_ref() - .inspect_err(|e| rocket::error!("Failed to initialize Docker client: {}", e)) - .map_err(|_| ToolError::ToolExecutionError("Failed to initialize Docker".into()))?; - docker - .ping() - .await - .inspect_err(|e| rocket::warn!("Failed to ping Docker daemon: {}", e)) - .map_err(|_| ToolError::ToolExecutionError("Couldn't connect to Docker".into()))?; - - // Run the code in a Docker container, returning early if the client disconnects - let result = tokio::select! { - result = self.run(docker, code, dependencies, &tx) => result, - _ = tx.closed() => Err(ToolError::Cancelled("client disconnected".to_string())) - }; - - // Cleanup container and image - if !tx.is_closed() { - send_log(tx, "Cleaning up...".into()).await; - docker_cleanup(docker, &self.container_name, &self.image_tag).await; - } else { - let container_name = self.container_name.clone(); - let image_tag = self.image_tag.clone(); - tokio::spawn(async move { - docker_cleanup(docker, &container_name, &image_tag).await; - }); - } - - result - } - - async fn run( - &self, - docker: &Docker, - code: &str, - dependencies: &[String], - tx: &SenderWithLogging, - ) -> ToolResult { - let (base_image, file_name, cmd) = get_dockerfile_info(&self.lang); - - // Check if base image exists locally, pull if needed - send_log(tx, format!("Checking base image '{base_image}'...")).await; - if docker.inspect_image(base_image).await.is_err() { - send_log(tx, format!("Pulling base image '{base_image}'...")).await; - let image_options = CreateImageOptionsBuilder::new() - .from_image(base_image) - .build(); - let mut pull_image_stream = docker.create_image(Some(image_options), None, None); - while let Some(result) = pull_image_stream.next().await { - match result { - Ok(mut response) => { - let status = response.status.unwrap_or_default(); - let progress_detail = response.progress_detail.take().unwrap_or_default(); - if let Some(progress) = response.progress { - send_debug(tx, format!("Pulling image: {status} {progress}")).await; - } else if let Some((current, total)) = - progress_detail.current.zip(progress_detail.total) - { - send_debug(tx, format!("Pulling image: {status} {current}/{total}")) - .await; - } - if let Some(error_detail) = response.error_detail { - send_error(tx, format!("Error pulling image: {:?}", error_detail)) - .await; - } - } - Err(err) => { - let message = format!("Error pulling image: {err}"); - send_error(tx, message.clone()).await; - return Err(ToolError::ToolExecutionError(message)); - } - } - } - } - - // Create tar archive with build context (Dockerfile and code files) - let (tar_writer, tar_reader) = tokio::io::duplex(8192); // 8KB buffer - let dockerfile = get_dockerfile(&self.lang); - let code = code.to_owned(); - send_log(tx, "Creating build context with 2 files...".into()).await; - - let tar_creation_task = tokio::spawn(async move { - let mut tar = tokio_tar::Builder::new(tar_writer); - for (path, content) in [("Dockerfile", dockerfile), (file_name, &code)] { - let mut header = tokio_tar::Header::new_gnu(); - header.set_size(content.len() as u64); - header.set_mode(0o644); - tar.append_data(&mut header, path, content.as_bytes()) - .await?; - } - tar.finish().await - }); - - // Build Docker image (streaming the build context tar file) - send_log(tx, format!("Building image '{}'...", self.image_tag)).await; - let build_options = BuildImageOptionsBuilder::new() - .buildargs(&[("DEPENDENCIES", self.build_dependency_string(dependencies))].into()) - .t(&self.image_tag) - .build(); - let mut build_stream = docker.build_image( - build_options, - None, - Some(body_try_stream(ReaderStream::new(tar_reader))), - ); - - let mut build_logs = String::new(); - let mut image_id = None; - while let Some(build_info_result) = build_stream.next().await { - match build_info_result { - Ok(info) => { - if let Some(id) = info.aux.and_then(|aux| aux.id) { - image_id = Some(id); - } - if let Some(stream) = info.stream { - build_logs.push_str(&format!("{stream}\n")); - send_debug(tx, stream).await; - } - if let Some(err) = info.error_detail.and_then(|e| e.message) { - build_logs.push_str(&format!("{err}\n")); - send_error(tx, format!("Error during build: {err}")).await; - } - } - Err(err) => { - build_logs.push_str(&format!("{err}\n")); - send_error(tx, format!("Error during build: {err}")).await; - } - } - } - if let Ok(Err(err)) = tar_creation_task.await { - let message = format!("Error while creating build context: {err}"); - send_error(tx, message).await; - } - if let Some(image_id) = image_id { - let message = format!("Built image '{}' with ID {}", self.image_tag, image_id); - send_log(tx, message).await; - } else { - let message = format!("Failed to build image '{}'", self.image_tag); - send_error(tx, message).await; - return Err(ToolError::ToolExecutionError(format!( - "Failed to build image '{}'. Build logs:\n\n{build_logs}", - self.image_tag - ))); - } - - // Create container with run command - let timeout_str = format!("{}s", self.timeout_seconds + GRACE_PERIOD_SECONDS); - let run_command = ["timeout", &timeout_str, "sh", "-c", &cmd]; - let container_body = ContainerCreateBody { - image: Some(self.image_tag.clone()), - cmd: Some(run_command.iter().map(|s| s.to_string()).collect()), - env: Some(vec!["HOME=/tmp/home".into()]), - network_disabled: Some(!self.network), - host_config: Some(HostConfig { - readonly_rootfs: Some(true), - tmpfs: Some([("/tmp".into(), "rw,noexec,nosuid,size=100m".into())].into()), - memory: Some((self.memory_limit_mb * 1024 * 1024).into()), - nano_cpus: Some((self.cpu_limit * 1000.0).round() as i64 * 1_000_000), - pids_limit: Some(50), - ulimits: Some(vec![ResourcesUlimits { - name: Some("nproc".into()), - soft: Some(50), - hard: Some(50), - }]), - cap_drop: Some(vec!["ALL".into()]), - security_opt: Some(vec!["no-new-privileges".into()]), - ..Default::default() - }), - ..Default::default() - }; - let container_options = CreateContainerOptionsBuilder::new() - .name(&self.container_name) - .build(); - match docker - .create_container(Some(container_options), container_body) - .await - { - Ok(res) => { - if !res.warnings.is_empty() { - let message = format!( - "⚠️ Warning while creating container '{}': {}", - self.container_name, - res.warnings.join(", ") - ); - send_log(tx, message).await; - } - } - Err(err) => { - let message = format!( - "Failed to create container '{}': {err}", - self.container_name - ); - send_error(tx, message.clone()).await; - return Err(ToolError::ToolExecutionError(message)); - } - }; - - // Spawn task to attach to container and capture logs/output - let attach_options = AttachContainerOptionsBuilder::new() - .stream(true) - .stdout(true) - .stderr(true) - .logs(true) - .build(); - let attached_container = match docker - .attach_container(&self.container_name, Some(attach_options)) - .await - { - Ok(container) => container, - Err(e) => { - let message = format!( - "Failed to attach to container '{}': {e}", - self.container_name - ); - send_error(tx, message.clone()).await; - return Err(ToolError::ToolExecutionError(message)); - } - }; - let output_tx = tx.clone(); - let output_timeout_secs = self.timeout_seconds + GRACE_PERIOD_SECONDS; - let container_output_task = tokio::spawn(async move { - let mut stdout = String::new(); - let mut stderr = String::new(); - let _ = tokio::time::timeout( - Duration::from_secs(output_timeout_secs.into()), - capture_container_output(attached_container, &mut stdout, &mut stderr, &output_tx), - ) - .await; - (stdout, stderr) - }); - - // Start container - send_log(tx, format!("Running command {run_command:?}...")).await; - if let Err(e) = docker - .start_container(&self.container_name, None::) - .await - { - let message = format!("Failed to start container '{}': {e}", self.container_name); - send_error(tx, message.clone()).await; - return Err(ToolError::ToolExecutionError(message)); - } - - // Wait for container to exit and get exit status - let container_exit_result = tokio::time::timeout( - Duration::from_secs(self.timeout_seconds.into()), - docker - .wait_container(&self.container_name, None::) - .next(), - ) - .await; - - // Process output and exit status - let (stdout, stderr) = container_output_task.await.unwrap_or_default(); - let output_text = format!("Output (stdout):\n\n{stdout}\n\nLogs (stderr):\n\n{stderr}\n"); - let output_markdown = - format!("## Output (stdout):\n```text\n{stdout}\n```\n## Logs (stderr):\n```text\n{stderr}\n```\n"); - match container_exit_result { - Err(_) => { - send_error(tx, "Code execution timed out".into()).await; - Err(ToolError::ToolExecutionError(format!( - "❌ Code execution timed out.\n\n{output_text}" - ))) - } - Ok(Some(wait_result)) => match wait_result { - Ok(_) => Ok(format!( - "✅ Code executed successfully!\n\n{output_markdown}" - )), - Err(err) => { - if let bollard::errors::Error::DockerContainerWaitError { code, .. } = err { - let message = format!("Code execution failed with exit status {code}"); - send_error(tx, message.clone()).await; - Err(ToolError::ToolExecutionError(format!( - "❌ {message}.\n\n{output_text}" - ))) - } else { - send_error(tx, "Code execution failed".into()).await; - Err(ToolError::ToolExecutionError(format!( - "❌ Code execution failed.\n\n{output_text}" - ))) - } - } - }, - Ok(None) => Ok(format!( - "Code executed with unknown exit status.\n\n{output_markdown}" - )), - } - } - - fn build_dependency_string(&self, dependencies: &[String]) -> String { - dependencies - .iter() - .filter_map(|d| { - let sanitized = self.sanitize_package_name(d); - if sanitized.trim().is_empty() { - None - } else { - Some(sanitized) - } - }) - .collect::>() - .join(" ") - } - - fn sanitize_package_name(&self, package: &str) -> String { - const ALLOWED_SYMBOLS: &[char] = &['-', '_', '.', '=', '"', ':', '/', '@']; - let sanitized = package - .chars() - .filter(|c| c.is_alphanumeric() || ALLOWED_SYMBOLS.contains(c)) - .collect::(); - - sanitized - } -} - -/// Capture stdout and stderr from the attached container -async fn capture_container_output( - mut attached_container: AttachContainerResults, - stdout: &mut String, - stderr: &mut String, - tx: &SenderWithLogging, -) { - while let Some(output_result) = attached_container.output.next().await { - match output_result { - Ok(output) => match output { - LogOutput::StdOut { message } => { - let message_str = String::from_utf8_lossy(&message).into_owned(); - stdout.push_str(&format!("{message_str}\n")); - tx.send(ToolLog::Result(message_str)).await.ok(); - } - LogOutput::StdErr { message } => { - let message_str = String::from_utf8_lossy(&message).into_owned(); - stderr.push_str(&format!("{message_str}\n")); - tx.send(ToolLog::Result(message_str)).await.ok(); - } - _ => {} - }, - Err(e) => { - let _ = tx.send(ToolLog::Error(e.to_string())).await; - } - } - } -} - -async fn docker_cleanup(docker: &Docker, container_name: &str, image_tag: &str) { - let _ = docker - .stop_container(container_name, None::) - .await; - let _ = tokio::join!( - docker.remove_container( - container_name, - Some(RemoveContainerOptionsBuilder::new().force(true).build()), - ), - docker.remove_image( - image_tag, - Some(RemoveImageOptionsBuilder::new().force(true).build()), - None, - ) - ); -} - -async fn send_log(tx: &SenderWithLogging, message: String) { - let _ = tx.send(ToolLog::Log(message)).await; -} -async fn send_debug(tx: &SenderWithLogging, message: String) { - let _ = tx.send(ToolLog::Debug(message)).await; -} -async fn send_error(tx: &SenderWithLogging, message: String) { - let _ = tx.send(ToolLog::Error(message)).await; -} diff --git a/server/src/tools/system/code_runner/dockerfiles.rs b/server/src/tools/system/code_runner/dockerfiles.rs deleted file mode 100644 index 55284ad..0000000 --- a/server/src/tools/system/code_runner/dockerfiles.rs +++ /dev/null @@ -1,162 +0,0 @@ -use const_format::formatcp; - -use super::CodeLanguage; - -pub fn get_dockerfile(language: &CodeLanguage) -> &'static str { - match language { - CodeLanguage::JavaScript => JS_DOCKERFILE, - CodeLanguage::TypeScript => TS_DOCKERFILE, - CodeLanguage::Python => PYTHON_DOCKERFILE, - CodeLanguage::Rust => RUST_DOCKERFILE, - CodeLanguage::Go => GO_DOCKERFILE, - CodeLanguage::Bash => BASH_DOCKERFILE, - } -} - -pub fn get_dockerfile_info(language: &CodeLanguage) -> (&'static str, &'static str, &'static str) { - let (base_image, file_name, cmd) = match language { - CodeLanguage::JavaScript => (JS_IMAGE, "main.js", "node main.js"), - CodeLanguage::TypeScript => (JS_IMAGE, "main.ts", "pnpm tsx main.ts"), - CodeLanguage::Python => (PYTHON_IMAGE, "main.py", "python main.py"), - CodeLanguage::Rust => (RUST_IMAGE, "main.rs", "./target/debug/temp"), - CodeLanguage::Go => (GO_IMAGE, "main.go", "./temp"), - CodeLanguage::Bash => (BASH_IMAGE, "script.sh", "bash script.sh"), - }; - (base_image, file_name, cmd) -} - -const JS_IMAGE: &str = "node:20-slim"; -const PYTHON_IMAGE: &str = "python:3.13-slim"; -const RUST_IMAGE: &str = "rust:1.85-slim"; -const GO_IMAGE: &str = "golang:1.24"; -const BASH_IMAGE: &str = "bash:5.3"; - -const SET_USER_AND_HOME_DIR: &str = r#" -RUN mkdir -p /app && chown 1000:1000 /app -USER 1000:1000 -RUN mkdir -p /tmp/home -WORKDIR /app -"#; - -const JS_DOCKERFILE: &str = formatcp!( - r#" -FROM {JS_IMAGE} - -ARG DEPENDENCIES -ENV PNPM_HOME="/opt/pnpm" -ENV PATH="$PNPM_HOME:$PATH" - -RUN mkdir -p /opt/pnpm && chown 1000:1000 /opt/pnpm -RUN npm install -g pnpm@9 - -{SET_USER_AND_HOME_DIR} - -RUN pnpm init -RUN if [ -n "$DEPENDENCIES" ]; then pnpm install $DEPENDENCIES; fi - -COPY main.js . - -CMD ["node", "main.js"] -"# -); - -const TS_DOCKERFILE: &str = formatcp!( - r#" -FROM {JS_IMAGE} - -ARG DEPENDENCIES -ENV PNPM_HOME="/opt/pnpm" -ENV PATH="$PNPM_HOME:$PATH" - -RUN mkdir -p /opt/pnpm && chown 1000:1000 /opt/pnpm -RUN npm install -g pnpm@9 - -{SET_USER_AND_HOME_DIR} - -RUN pnpm init -RUN pnpm install tsx $DEPENDENCIES - -COPY main.ts . - -CMD ["pnpm", "tsx", "main.ts"] -"# -); - -const PYTHON_DOCKERFILE: &str = formatcp!( - r#" -FROM {PYTHON_IMAGE} - -ARG DEPENDENCIES -ENV PYTHONUNBUFFERED=1 -ENV PYTHONUSERBASE="/opt/python" -ENV PATH="/opt/python/bin:$PATH" - -RUN mkdir -p /opt/python && chown 1000:1000 /opt/python - -{SET_USER_AND_HOME_DIR} - -RUN if [ -n "$DEPENDENCIES" ]; then pip install --user --no-cache-dir $DEPENDENCIES; fi - -COPY main.py . - -CMD ["python", "main.py"] -"# -); - -const RUST_DOCKERFILE: &str = formatcp!( - r#" -FROM {RUST_IMAGE} -RUN apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev ca-certificates && apt-get clean - -ARG DEPENDENCIES - -{SET_USER_AND_HOME_DIR} - -RUN cargo init --name temp -RUN if [ -n "$DEPENDENCIES" ]; then cargo add $DEPENDENCIES; fi -RUN cargo build - -COPY --chown=1000:1000 main.rs src/ -RUN touch src/main.rs -RUN cargo build - -CMD ["./target/debug/temp"] -"# -); - -const GO_DOCKERFILE: &str = formatcp!( - r#" -FROM {GO_IMAGE} - -ARG DEPENDENCIES - -ENV GOTMPDIR=/opt/gotmpdir GOCACHE=/opt/gocache -RUN mkdir -p /opt/gotmpdir && chown 1000:1000 /opt/gotmpdir -RUN mkdir -p /opt/gocache && chown 1000:1000 /opt/gocache - -{SET_USER_AND_HOME_DIR} - -RUN go mod init temp -RUN if [ -n "$DEPENDENCIES" ]; then go get $DEPENDENCIES; fi - -COPY main.go . -RUN go build - -CMD ["./temp"] -"# -); - -const BASH_DOCKERFILE: &str = formatcp!( - r#" -FROM {BASH_IMAGE} - -ARG DEPENDENCIES -RUN if [ -n "$DEPENDENCIES" ]; then apk add --no-cache $DEPENDENCIES; fi - -{SET_USER_AND_HOME_DIR} - -COPY script.sh . - -CMD ["bash", "script.sh"] -"# -); diff --git a/server/src/tools/system/code_runner/tinirun.rs b/server/src/tools/system/code_runner/tinirun.rs new file mode 100644 index 0000000..571393d --- /dev/null +++ b/server/src/tools/system/code_runner/tinirun.rs @@ -0,0 +1,129 @@ +use rocket::futures::StreamExt; +use tinirun_client::{ + models::{CodeRunnerChunk, CodeRunnerError, CodeRunnerLanguage}, + TinirunClient, +}; + +use crate::{ + tools::{ + core::{ToolLog, ToolResult}, + system::code_runner::CodeLanguage, + ToolError, + }, + utils::SenderWithLogging, +}; + +pub struct TinirunExecutor<'a> { + client: &'a TinirunClient, + lang: CodeLanguage, + timeout_seconds: u32, + memory_limit_mb: u32, + cpu_limit: f32, +} + +#[derive(Debug, Default)] +pub struct TinirunExecutorOptions { + pub timeout_seconds: u32, + pub memory_limit_mb: u32, + pub cpu_limit: f32, +} + +impl<'a> TinirunExecutor<'a> { + pub fn new( + client: &'a TinirunClient, + lang: CodeLanguage, + options: TinirunExecutorOptions, + ) -> Self { + TinirunExecutor { + client, + lang, + timeout_seconds: options.timeout_seconds, + memory_limit_mb: options.memory_limit_mb, + cpu_limit: options.cpu_limit, + } + } + + pub async fn execute( + &self, + code: &str, + dependencies: &[String], + tx: &SenderWithLogging, + ) -> ToolResult { + let input = tinirun_client::models::CodeRunnerInput { + code: code.to_owned(), + lang: self.lang.into(), + dependencies: Some(dependencies.to_vec()), + files: None, + timeout: self.timeout_seconds, + mem_limit_mb: self.memory_limit_mb, + cpu_limit: self.cpu_limit, + }; + let mut stream = match self.client.run_code(&input).await { + Ok(stream) => stream, + Err(err) => { + return Err(ToolError::ToolExecutionError(err.to_string())); + } + }; + while let Some(item) = stream.next().await { + match item { + Ok(event) => match event { + CodeRunnerChunk::Info(log) => tx.send(ToolLog::Log(log)).await.ok(), + CodeRunnerChunk::Debug(log) => tx.send(ToolLog::Debug(log)).await.ok(), + CodeRunnerChunk::Stdout(stdout) => tx.send(ToolLog::Result(stdout)).await.ok(), + CodeRunnerChunk::Stderr(stderr) => tx.send(ToolLog::Result(stderr)).await.ok(), + CodeRunnerChunk::Error(err) => { + tx.send(ToolLog::Error(err.to_string())).await.ok(); + let error_message = match err { + CodeRunnerError::BuildFailed { message, logs } => { + format!("{}\n## Build logs\n{}", message, logs) + } + _ => err.to_string(), + }; + return Err(ToolError::ToolExecutionError(error_message)); + } + CodeRunnerChunk::Result { + stdout, + stderr, + exit_code, + timeout, + } => { + let mut markdown = String::new(); + if let Some(code) = exit_code { + if code != 0 { + markdown += &format!("⚠️ Program exited with code {code}\n\n"); + } + } else if timeout { + markdown += + &format!("⚠️ Timed out after {} seconds\n\n", self.timeout_seconds); + } + + if !stdout.is_empty() { + markdown += &format!("Output:\n{stdout}\n\n"); + } + if !stderr.is_empty() { + markdown += &format!("Stderr:\n{stderr}\n"); + } + + return Ok(markdown); + } + }, + Err(err) => tx.send(ToolLog::Error(err.to_string())).await.ok(), + }; + } + + Err(ToolError::ToolExecutionError("No output".to_string())) + } +} + +impl From for CodeRunnerLanguage { + fn from(language: CodeLanguage) -> Self { + match language { + CodeLanguage::Python => CodeRunnerLanguage::Python, + CodeLanguage::JavaScript => CodeRunnerLanguage::JavaScript, + CodeLanguage::TypeScript => CodeRunnerLanguage::TypeScript, + CodeLanguage::Rust => CodeRunnerLanguage::Rust, + CodeLanguage::Go => CodeRunnerLanguage::Go, + CodeLanguage::Bash => CodeRunnerLanguage::Bash, + } + } +} diff --git a/server/src/utils/generate_title.rs b/server/src/utils/generate_title.rs index 2146608..0108780 100644 --- a/server/src/utils/generate_title.rs +++ b/server/src/utils/generate_title.rs @@ -47,6 +47,7 @@ async fn generate( model, temperature: Some(DEFAULT_TEMPERATURE), max_tokens: Some(TITLE_TOKENS), + ..Default::default() }; let message = format!("{}: \"{}\"", TITLE_PROMPT, user_message); let title = provider.prompt(&message, &provider_options).await?; diff --git a/web/package.json b/web/package.json index f7b36fa..d8f2abe 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,7 @@ "lint": "biome lint", "lint:ci": "biome ci", "format": "biome check --linter-enabled=false", - "gen-api": "pnpm dlx openapi-typescript http://localhost:8000/api/openapi.json -o src/lib/api/types.d.ts" + "gen-api": "pnpm dlx openapi-typescript http://localhost:8080/api/v1/docs/openapi.json -o src/lib/api/types.d.ts" }, "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/web/src/components/ProviderManager.tsx b/web/src/components/ProviderManager.tsx index 8ec08ff..6ef52b2 100644 --- a/web/src/components/ProviderManager.tsx +++ b/web/src/components/ProviderManager.tsx @@ -78,7 +78,7 @@ const PROVIDERS: Record = { baseUrl: "https://openrouter.ai/api/v1", keyFormat: "sk-or-...", color: "bg-blue-100 dark:bg-blue-900 border-blue-300 dark:border-blue-700", - defaultModel: "moonshotai/kimi-k2.5", + defaultModel: "openai/gpt-4o-mini", }, ollama: { name: "Ollama", diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index ed606ab..9d5828e 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -83,7 +83,7 @@ export function AppSidebar({ ); const onLogout = React.useCallback(async () => { - await fetch(`${import.meta.env.VITE_API_URL || ""}/api/auth/logout`, { + await fetch(`${import.meta.env.VITE_API_URL || "/api/v1"}/auth/logout`, { method: "POST", }); queryClient.invalidateQueries({ queryKey: ["user"] }); diff --git a/web/src/components/chat/ChatMessageInput.tsx b/web/src/components/chat/ChatMessageInput.tsx index 018665d..973288f 100644 --- a/web/src/components/chat/ChatMessageInput.tsx +++ b/web/src/components/chat/ChatMessageInput.tsx @@ -1,7 +1,7 @@ import { CornerDownLeft, Paperclip, Upload, X } from "lucide-react"; import { - type FormEventHandler, memo, + type SubmitEventHandler, useCallback, useMemo, useState, @@ -35,9 +35,10 @@ export default memo(function ChatMessageInput({ const isMobile = useIsMobile(); const { + sessionId, providerId, modelId, - sessionId, + selectedModel, toolInput, files, maxTokens, @@ -84,7 +85,7 @@ export default memo(function ChatMessageInput({ [enterKeyShouldSubmit, onSubmitUserMessage], ); - const handleFormSubmit: FormEventHandler = useCallback( + const handleFormSubmit: SubmitEventHandler = useCallback( (ev) => { ev.preventDefault(); onSubmitUserMessage(); @@ -197,13 +198,16 @@ export default memo(function ChatMessageInput({ currentTemperature={temperature} onSelectMaxTokens={setMaxTokens} onSelectTemperature={setTemperature} + showTemperature={selectedModel?.temperature} /> - + {(!selectedModel || selectedModel.tool_call) && ( + + )} (null); + const { scrollRef } = useAutoScroll({ contentRef }); return ( -
- {children} +
+
+ {children} +
); } diff --git a/web/src/components/chat/messages/ChatMessage.tsx b/web/src/components/chat/messages/ChatMessage.tsx index 8162e0d..bb28b70 100644 --- a/web/src/components/chat/messages/ChatMessage.tsx +++ b/web/src/components/chat/messages/ChatMessage.tsx @@ -86,6 +86,19 @@ export default function ChatMessage({ onExecute={(id) => onExecuteToolCall(message.id, id)} /> )} + {message.meta.assistant?.files?.map((fileId) => ( + + ))}
diff --git a/web/src/components/chat/messages/ChatMessageToolLogs.tsx b/web/src/components/chat/messages/ChatMessageToolLogs.tsx index 46a527a..04e6873 100644 --- a/web/src/components/chat/messages/ChatMessageToolLogs.tsx +++ b/web/src/components/chat/messages/ChatMessageToolLogs.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronUp } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { useAutoScroll } from "@/components/ui/chat/hooks/useAutoScroll"; @@ -18,6 +18,9 @@ export default function ChatMessageToolLogs({ }) { const [showLogs, setShowLogs] = useState(initialOpen ?? false); + const contentRef = useRef(null); + const { scrollRef } = useAutoScroll({ contentRef }); + return ( @@ -28,27 +31,19 @@ export default function ChatMessageToolLogs({ - - {logs.map((log, index) => ( -
- {log} -
- ))} -
+
+
+ {logs.map((log, index) => ( +
+ {log} +
+ ))} +
+
); } - -function LogsContent({ children }: { children: React.ReactNode }) { - const { scrollRef } = useAutoScroll(); - - return ( -
- {children} -
- ); -} diff --git a/web/src/components/chat/messages/ChatMessageToolResult.tsx b/web/src/components/chat/messages/ChatMessageToolResult.tsx index 8a7071a..f511d74 100644 --- a/web/src/components/chat/messages/ChatMessageToolResult.tsx +++ b/web/src/components/chat/messages/ChatMessageToolResult.tsx @@ -23,7 +23,7 @@ export default function ChatMessageToolResult({ message: components["schemas"]["ChatRsMessage"]; tools?: components["schemas"]["GetAllToolsResponse"]; }) { - const [showOutput, setShowOutput] = useState(false); + const [showOutput, setShowOutput] = useState(true); const tool = useMemo(() => { if (!message.meta.tool_call) return null; diff --git a/web/src/components/chat/settings/ChatModelSelect.tsx b/web/src/components/chat/settings/ChatModelSelect.tsx index 7d08e7c..1e7b02b 100644 --- a/web/src/components/chat/settings/ChatModelSelect.tsx +++ b/web/src/components/chat/settings/ChatModelSelect.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronsUpDown } from "lucide-react"; +import { ChevronsUpDown, Eye, FileText, Wrench } from "lucide-react"; import React from "react"; import PopoverDrawer from "@/components/PopoverDrawer"; @@ -37,7 +37,7 @@ export default function ChatModelSelect({ variant="outline" role="combobox" aria-expanded={open} - className="w-[180px] md:w-[240px] justify-between" + className="w-45 md:w-60 justify-between" > {currentModelId @@ -54,29 +54,35 @@ export default function ChatModelSelect({ No models found. - {models?.map((model) => ( - { - onSelect(model.id); - setOpen(false); - }} - > -
- {model.name} - - {model.id} - -
- -
- ))} + {models + ?.toSorted((a, _) => (a.id === currentModelId ? -1 : 0)) + .map((model) => ( + { + onSelect(model.id); + setOpen(false); + }} + > +
+ {model.name} + + {model.id} + +
+
+ {model.tool_call && } + {model.modalities?.input.includes("image") && } + {model.modalities?.input.includes("pdf") && } +
+
+ ))}
diff --git a/web/src/components/chat/settings/ChatMoreSettings.tsx b/web/src/components/chat/settings/ChatMoreSettings.tsx index 52694c9..6e1259e 100644 --- a/web/src/components/chat/settings/ChatMoreSettings.tsx +++ b/web/src/components/chat/settings/ChatMoreSettings.tsx @@ -18,6 +18,7 @@ interface Props { onSelectMaxTokens: (tokens: number) => void; currentTemperature: number; onSelectTemperature: (temperature: number) => void; + showTemperature?: boolean | null; } export default function ChatMoreSettings({ @@ -25,6 +26,7 @@ export default function ChatMoreSettings({ onSelectMaxTokens, currentTemperature, onSelectTemperature, + showTemperature = true, }: Props) { return ( onSelectMaxTokens(+tokens)} > - + @@ -57,32 +59,34 @@ export default function ChatMoreSettings({ - + {showTemperature && ( + + )}
); diff --git a/web/src/components/chat/settings/ChatProviderSelect.tsx b/web/src/components/chat/settings/ChatProviderSelect.tsx index a5bc878..677f076 100644 --- a/web/src/components/chat/settings/ChatProviderSelect.tsx +++ b/web/src/components/chat/settings/ChatProviderSelect.tsx @@ -37,7 +37,7 @@ export default function ChatProviderSelect({ variant="outline" role="combobox" aria-expanded={open} - className="w-[130px] md:w-[160px] justify-between" + className="w-32.5 md:w-40 justify-between" > {currentProvider ? currentProvider.name : "Select provider"} diff --git a/web/src/components/chat/settings/ChatSettingsBadge.tsx b/web/src/components/chat/settings/ChatSettingsBadge.tsx index fae864f..79d0d36 100644 --- a/web/src/components/chat/settings/ChatSettingsBadge.tsx +++ b/web/src/components/chat/settings/ChatSettingsBadge.tsx @@ -6,7 +6,7 @@ export default function ChatSettingsBadge({ children: React.ReactNode; }) { return ( - + {children} ); diff --git a/web/src/components/ui/chat/chat-message-list.tsx b/web/src/components/ui/chat/chat-message-list.tsx index ddc1813..1e50875 100644 --- a/web/src/components/ui/chat/chat-message-list.tsx +++ b/web/src/components/ui/chat/chat-message-list.tsx @@ -14,7 +14,6 @@ const ChatMessageList = React.forwardRef( const { scrollRef, isAtBottom, scrollToBottom, disableAutoScroll } = useAutoScroll({ smooth, - contentRef, }); diff --git a/web/src/hooks/useChatInputState.tsx b/web/src/hooks/useChatInputState.tsx index cba1896..bc83a79 100644 --- a/web/src/hooks/useChatInputState.tsx +++ b/web/src/hooks/useChatInputState.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useProviderModels } from "@/lib/api/provider"; import type { components } from "@/lib/api/types"; const DEFAULT_MAX_TOKENS = 2000; @@ -40,17 +41,26 @@ export const useChatInputState = ({ () => providers?.find((p) => p.id === providerId), [providers, providerId], ); + + const { data: models } = useProviderModels(providerId); const [modelId, setModel] = useState(initialOptions?.model || ""); + const selectedModel = useMemo( + () => models?.find((m) => m.id === modelId), + [models, modelId], + ); + const [toolInput, setToolInput] = useState< components["schemas"]["SendChatToolInput"] | null >(initialTools || DEFAULT_TOOL_INPUT); const [files, setFiles] = useState([]); + const [maxTokens, setMaxTokens] = useState( initialOptions?.max_tokens ?? DEFAULT_MAX_TOKENS, ); const [temperature, setTemperature] = useState( initialOptions?.temperature ?? DEFAULT_TEMPERATURE, ); + const [error, setError] = useState(""); // Reset state when session changes @@ -151,16 +161,18 @@ export const useChatInputState = ({ provider_id: providerId, options: { model: modelId, - temperature, + temperature: selectedModel?.temperature ? temperature : undefined, max_tokens: maxTokens, + modalities: selectedModel?.modalities?.output, }, - tools: toolInput, + tools: selectedModel?.tool_call ? toolInput : undefined, files: files.length > 0 ? files.map((file) => file.id) : undefined, }); formRef.current?.reset(); }, [ providerId, selectedProvider, + selectedModel, modelId, toolInput, files, @@ -178,14 +190,16 @@ export const useChatInputState = ({ provider_id: providerId, options: { model: modelId, - temperature, + temperature: selectedModel?.temperature ? temperature : undefined, max_tokens: maxTokens, + modalities: selectedModel?.modalities?.output, }, - tools: toolInput, + tools: selectedModel?.tool_call ? toolInput : undefined, }); }, [ providerId, modelId, + selectedModel, toolInput, temperature, maxTokens, @@ -198,6 +212,7 @@ export const useChatInputState = ({ providerId, modelId, sessionId, + selectedModel, toolInput, files, maxTokens, @@ -222,6 +237,7 @@ export const useChatInputState = ({ providerId, modelId, sessionId, + selectedModel, toolInput, files, maxTokens, diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 7efc425..4ff822c 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -3,7 +3,7 @@ import createClient from "openapi-fetch"; import type { paths } from "./types"; -export const API_URL: string = import.meta.env.VITE_API_URL || "/api"; +export const API_URL: string = import.meta.env.VITE_API_URL || "/api/v1"; export const client = createClient({ baseUrl: API_URL, diff --git a/web/src/lib/api/provider.ts b/web/src/lib/api/provider.ts index ddb6aeb..ecd4beb 100644 --- a/web/src/lib/api/provider.ts +++ b/web/src/lib/api/provider.ts @@ -31,6 +31,7 @@ export const useProviderModels = (providerId?: number | null) => if (response.error) { throw new Error(response.error.message); } + response.data.sort((a, b) => a.name.localeCompare(b.name)); return response.data; }, }); diff --git a/web/src/lib/api/types.d.ts b/web/src/lib/api/types.d.ts index 8cb7c4b..773a8cc 100644 --- a/web/src/lib/api/types.d.ts +++ b/web/src/lib/api/types.d.ts @@ -781,6 +781,8 @@ export interface components { provider_options?: components["schemas"]["LlmProviderOptions"] | null; /** @description The tool calls requested by the assistant */ tool_calls?: components["schemas"]["ChatRsToolCall"][] | null; + /** @description IDs of generated files */ + files?: string[] | null; /** @description Provider usage information */ usage?: components["schemas"]["LlmUsage"] | null; /** @description Errors encountered during message generation */ @@ -795,6 +797,8 @@ export interface components { temperature?: number | null; /** Format: uint32 */ max_tokens?: number | null; + /** @description Only supported for OpenRouter */ + modalities?: components["schemas"]["ModalityType"][] | null; }; /** @description A tool call requested by the provider */ ChatRsToolCall: { diff --git a/web/vite.config.ts b/web/vite.config.ts index a18f384..63b1a4c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -16,8 +16,8 @@ export default defineConfig({ ], server: { proxy: { - "/api": { - target: "http://localhost:8000", + "/api/v1": { + target: "http://localhost:8080", changeOrigin: true, secure: false, },