Преглед изворни кода

feat: implement LLM assistant system with chat UI

Add complete LLM assistant integration:

Backend:
- New smartbotic-crm-llm gRPC node with 4 services (LLM, Session, Model, Config)
- Provider abstraction layer supporting OpenAI and Anthropic compatible APIs
- Database-backed session storage via gRPC client
- Tool registry infrastructure for future tool additions
- HTTP endpoints for chat sessions, messages, providers, and admin management
- BYOK (Bring Your Own Key) support for workspaces
- New permissions for LLM access control

Frontend:
- ChatContext for state management with SSE streaming
- Chat widget components (ChatIcon, ChatWindow, ChatInput, ChatMessage, etc.)
- Page context tracking via usePageContext hook
- LLM Providers management page for admin configuration
- Permission-based visibility using superadmin check

Configuration:
- Systemd service file for LLM node
- Updated webserver service with LLM dependency
fszontagh пре 6 месеци
родитељ
комит
e9f57b9a97
62 измењених фајлова са 9638 додато и 4 уклоњено
  1. 1 0
      CMakeLists.txt
  2. 87 0
      llm/CMakeLists.txt
  3. 95 0
      llm/include/smartbotic/llm/config.hpp
  4. 92 0
      llm/include/smartbotic/llm/grpc/config_service.hpp
  5. 54 0
      llm/include/smartbotic/llm/grpc/llm_service.hpp
  6. 35 0
      llm/include/smartbotic/llm/grpc/model_service.hpp
  7. 51 0
      llm/include/smartbotic/llm/grpc/proto_convert.hpp
  8. 87 0
      llm/include/smartbotic/llm/grpc/server.hpp
  9. 49 0
      llm/include/smartbotic/llm/grpc/session_service.hpp
  10. 51 0
      llm/include/smartbotic/llm/provider/anthropic_provider.hpp
  11. 48 0
      llm/include/smartbotic/llm/provider/openai_provider.hpp
  12. 49 0
      llm/include/smartbotic/llm/provider/provider_factory.hpp
  13. 188 0
      llm/include/smartbotic/llm/provider/provider_interface.hpp
  14. 38 0
      llm/include/smartbotic/llm/server_config.hpp
  15. 78 0
      llm/include/smartbotic/llm/session/database_session_store.hpp
  16. 104 0
      llm/include/smartbotic/llm/session/session.hpp
  17. 99 0
      llm/include/smartbotic/llm/session/session_store.hpp
  18. 38 0
      llm/include/smartbotic/llm/tool/tool_definition.hpp
  19. 59 0
      llm/include/smartbotic/llm/tool/tool_registry.hpp
  20. 415 0
      llm/src/config.cpp
  21. 393 0
      llm/src/grpc/config_service.cpp
  22. 493 0
      llm/src/grpc/llm_service.cpp
  23. 151 0
      llm/src/grpc/model_service.cpp
  24. 289 0
      llm/src/grpc/proto_convert.cpp
  25. 116 0
      llm/src/grpc/server.cpp
  26. 158 0
      llm/src/grpc/session_service.cpp
  27. 168 0
      llm/src/main.cpp
  28. 607 0
      llm/src/provider/anthropic_provider.cpp
  29. 556 0
      llm/src/provider/openai_provider.cpp
  30. 103 0
      llm/src/provider/provider_factory.cpp
  31. 633 0
      llm/src/session/database_session_store.cpp
  32. 102 0
      llm/src/session/session.cpp
  33. 103 0
      llm/src/tool/tool_registry.cpp
  34. 1 0
      proto/CMakeLists.txt
  35. 459 0
      proto/proto/llm.proto
  36. 33 0
      systemd/smartbotic-llm.service
  37. 3 1
      systemd/smartbotic-webserver.service
  38. 1 0
      webserver/CMakeLists.txt
  39. 3 0
      webserver/include/smartbotic/webserver/config.hpp
  40. 32 0
      webserver/include/smartbotic/webserver/http_server.hpp
  41. 175 0
      webserver/include/smartbotic/webserver/llm_client.hpp
  42. 13 0
      webserver/include/smartbotic/webserver/permissions.hpp
  43. 1408 0
      webserver/src/http_server.cpp
  44. 312 0
      webserver/src/llm_client.cpp
  45. 9 0
      webserver/src/permissions.cpp
  46. 2 0
      webui/src/App.tsx
  47. 168 0
      webui/src/api/chat.ts
  48. 29 0
      webui/src/components/Chat/ChatIcon.tsx
  49. 74 0
      webui/src/components/Chat/ChatInput.tsx
  50. 71 0
      webui/src/components/Chat/ChatMessage.tsx
  51. 52 0
      webui/src/components/Chat/ChatMessageList.tsx
  52. 64 0
      webui/src/components/Chat/ChatWindow.tsx
  53. 136 0
      webui/src/components/Chat/SessionSidebar.tsx
  54. 29 0
      webui/src/components/Chat/index.tsx
  55. 8 0
      webui/src/components/layout/DashboardLayout.tsx
  56. 28 0
      webui/src/components/layout/Sidebar.tsx
  57. 309 0
      webui/src/contexts/ChatContext.tsx
  58. 67 0
      webui/src/hooks/usePageContext.ts
  59. 6 3
      webui/src/main.tsx
  60. 440 0
      webui/src/pages/LlmProviders.tsx
  61. 113 0
      webui/src/types/chat.ts
  62. 3 0
      webui/src/types/index.ts

+ 1 - 0
CMakeLists.txt

@@ -111,6 +111,7 @@ message(STATUS "  OpenSSL version: ${OPENSSL_VERSION}")
 add_subdirectory(common)
 add_subdirectory(proto)
 add_subdirectory(database)
+add_subdirectory(llm)
 add_subdirectory(webserver)
 add_subdirectory(webui)
 

+ 87 - 0
llm/CMakeLists.txt

@@ -0,0 +1,87 @@
+# LLM service - smartbotic-crm-llm binary and core libraries
+
+# Core LLM library (config, providers, sessions, tools)
+add_library(smartbotic_llm STATIC
+    src/config.cpp
+    src/provider/provider_factory.cpp
+    src/provider/openai_provider.cpp
+    src/provider/anthropic_provider.cpp
+    src/session/session.cpp
+    src/session/database_session_store.cpp
+    src/tool/tool_registry.cpp
+)
+
+target_include_directories(smartbotic_llm
+    PUBLIC
+        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
+        $<INSTALL_INTERFACE:include>
+)
+
+target_compile_options(smartbotic_llm PRIVATE ${SMARTBOTIC_CXX_WARNINGS})
+
+target_link_libraries(smartbotic_llm
+    PUBLIC
+        smartbotic::common
+        smartbotic::proto
+        spdlog::spdlog
+        nlohmann_json::nlohmann_json
+        httplib::httplib
+        OpenSSL::SSL
+        OpenSSL::Crypto
+)
+
+add_library(smartbotic::llm ALIAS smartbotic_llm)
+
+# gRPC service library
+add_library(smartbotic_llm_grpc STATIC
+    src/grpc/proto_convert.cpp
+    src/grpc/llm_service.cpp
+    src/grpc/session_service.cpp
+    src/grpc/model_service.cpp
+    src/grpc/config_service.cpp
+    src/grpc/server.cpp
+)
+
+target_include_directories(smartbotic_llm_grpc
+    PUBLIC
+        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
+        $<INSTALL_INTERFACE:include>
+)
+
+# Use relaxed warnings for gRPC targets (abseil uses __int128 which triggers -Wpedantic)
+set(SMARTBOTIC_GRPC_WARNINGS
+    -Wall
+    -Wextra
+    -Werror
+    -Wno-unused-parameter
+    -Wno-dangling-reference
+    -Wno-pedantic      # Required for abseil __int128 support
+    -Wno-overflow      # Required for abseil hash_set.h
+)
+target_compile_options(smartbotic_llm_grpc PRIVATE ${SMARTBOTIC_GRPC_WARNINGS})
+
+target_link_libraries(smartbotic_llm_grpc
+    PUBLIC
+        smartbotic::llm
+        smartbotic::proto
+        spdlog::spdlog
+)
+
+add_library(smartbotic::llm_grpc ALIAS smartbotic_llm_grpc)
+
+# LLM service executable
+add_executable(smartbotic-crm-llm
+    src/main.cpp
+)
+
+target_include_directories(smartbotic-crm-llm
+    PRIVATE
+        ${CMAKE_CURRENT_SOURCE_DIR}/include
+)
+
+target_compile_options(smartbotic-crm-llm PRIVATE ${SMARTBOTIC_GRPC_WARNINGS})
+
+target_link_libraries(smartbotic-crm-llm
+    PRIVATE
+        smartbotic::llm_grpc
+)

+ 95 - 0
llm/include/smartbotic/llm/config.hpp

@@ -0,0 +1,95 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::llm {
+
+/// Log level configuration
+enum class LogLevel : uint8_t {
+    kTrace,
+    kDebug,
+    kInfo,
+    kWarn,
+    kError,
+    kCritical,
+    kOff
+};
+
+/// Convert log level to string
+[[nodiscard]] auto LogLevelToString(LogLevel level) -> std::string;
+
+/// Parse log level from string (case-insensitive)
+[[nodiscard]] auto ParseLogLevel(const std::string& str) -> std::optional<LogLevel>;
+
+/// Complete LLM service configuration
+/// Supports loading from:
+/// 1. Config file (JSON) - smartbotic-llm.json
+/// 2. Command-line arguments (override config file)
+/// 3. Environment variables
+struct LlmConfig {
+    // Server configuration
+    std::string address = "0.0.0.0";  // Listen address
+    uint16_t port = 50052;            // Listen port
+
+    // Database node connection
+    std::string database_address = "127.0.0.1:50051";
+
+    // LLM defaults
+    std::string default_model = "gpt-4o";
+    int64_t session_timeout_seconds = 86400;  // 24 hours
+
+    // Logging configuration
+    LogLevel log_level = LogLevel::kInfo;
+
+    /// Get the full address string (address:port)
+    [[nodiscard]] auto GetListenAddress() const -> std::string {
+        return address + ":" + std::to_string(port);
+    }
+
+    /// Create default configuration
+    [[nodiscard]] static auto Default() -> LlmConfig { return LlmConfig{}; }
+};
+
+/// Configuration loader that handles:
+/// - JSON config file parsing
+/// - Command-line argument parsing
+/// - Environment variable substitution
+class ConfigLoader {
+public:
+    /// Load configuration from all sources
+    /// Priority (highest to lowest):
+    /// 1. Command-line arguments
+    /// 2. Environment variables
+    /// 3. Config file
+    /// 4. Built-in defaults
+    [[nodiscard]] static auto Load(int argc, char* argv[]) -> LlmConfig;
+
+    /// Load configuration from a specific config file
+    [[nodiscard]] static auto LoadFromFile(const std::string& path) -> std::optional<LlmConfig>;
+
+    /// Parse command-line arguments into a config
+    /// Returns nullopt if help was requested
+    [[nodiscard]] static auto ParseCommandLine(int argc, char* argv[], LlmConfig& config)
+        -> std::optional<bool>;
+
+    /// Apply environment variables to config
+    static void ApplyEnvironment(LlmConfig& config);
+
+    /// Get the default config file paths to search
+    [[nodiscard]] static auto GetDefaultConfigPaths() -> std::vector<std::string>;
+
+    /// Print help message
+    static void PrintHelp(const char* program_name);
+
+    /// Print current configuration
+    static void PrintConfig(const LlmConfig& config);
+
+private:
+    /// Find the first existing config file from the default paths
+    [[nodiscard]] static auto FindConfigFile() -> std::optional<std::string>;
+};
+
+}  // namespace smartbotic::llm

+ 92 - 0
llm/include/smartbotic/llm/grpc/config_service.hpp

@@ -0,0 +1,92 @@
+#pragma once
+
+#include <grpcpp/grpcpp.h>
+#include <memory>
+#include <mutex>
+#include <unordered_map>
+
+#include "database.grpc.pb.h"
+#include "llm.grpc.pb.h"
+#include "smartbotic/llm/provider/provider_factory.hpp"
+
+namespace smartbotic::llm::grpc {
+
+/// Manages provider configurations and workspace BYOK keys
+class ConfigServiceImpl final : public ::smartbotic::llm::ConfigService::Service {
+public:
+    ConfigServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory,
+                      const std::string& database_address);
+
+    /// Initialize the service (create collections, load providers)
+    [[nodiscard]] auto Initialize() -> bool;
+
+    ::grpc::Status GetProviderConfig(::grpc::ServerContext* context,
+                                     const ::smartbotic::llm::GetProviderConfigRequest* request,
+                                     ::smartbotic::llm::ProviderConfig* response) override;
+
+    ::grpc::Status SetProviderConfig(::grpc::ServerContext* context,
+                                     const ::smartbotic::llm::SetProviderConfigRequest* request,
+                                     ::smartbotic::llm::ProviderConfig* response) override;
+
+    ::grpc::Status DeleteProviderConfig(::grpc::ServerContext* context,
+                                        const ::smartbotic::llm::DeleteProviderConfigRequest* request,
+                                        ::smartbotic::llm::DeleteProviderConfigResponse* response) override;
+
+    ::grpc::Status ListProviderConfigs(::grpc::ServerContext* context,
+                                       const ::smartbotic::llm::ListProviderConfigsRequest* request,
+                                       ::smartbotic::llm::ListProviderConfigsResponse* response) override;
+
+    ::grpc::Status SetWorkspaceApiKey(::grpc::ServerContext* context,
+                                      const ::smartbotic::llm::SetWorkspaceApiKeyRequest* request,
+                                      ::smartbotic::llm::Empty* response) override;
+
+    ::grpc::Status GetWorkspaceApiKey(::grpc::ServerContext* context,
+                                      const ::smartbotic::llm::GetWorkspaceApiKeyRequest* request,
+                                      ::smartbotic::llm::WorkspaceApiKeyResponse* response) override;
+
+    ::grpc::Status DeleteWorkspaceApiKey(::grpc::ServerContext* context,
+                                         const ::smartbotic::llm::DeleteWorkspaceApiKeyRequest* request,
+                                         ::smartbotic::llm::DeleteWorkspaceApiKeyResponse* response) override;
+
+    /// Get API key for a provider (considers workspace BYOK)
+    [[nodiscard]] auto GetApiKey(const std::string& provider_id, const std::string& workspace_id)
+        -> std::string;
+
+private:
+    /// Connect to database
+    [[nodiscard]] auto ConnectToDatabase() -> bool;
+
+    /// Load all providers from database
+    [[nodiscard]] auto LoadProviders() -> bool;
+
+    /// Save a provider to database
+    [[nodiscard]] auto SaveProvider(const provider::ProviderConfig& config) -> bool;
+
+    /// Delete a provider from database
+    [[nodiscard]] auto DeleteProvider(const std::string& provider_id) -> bool;
+
+    /// Simple encryption for API keys (should use better crypto in production)
+    [[nodiscard]] auto EncryptApiKey(const std::string& key) -> std::string;
+    [[nodiscard]] auto DecryptApiKey(const std::string& encrypted) -> std::string;
+
+    std::shared_ptr<provider::ProviderFactory> provider_factory_;
+    std::string database_address_;
+
+    // Database client
+    std::shared_ptr<::grpc::Channel> db_channel_;
+    std::unique_ptr<::smartbotic::database::DocumentService::Stub> db_document_stub_;
+    std::unique_ptr<::smartbotic::database::CollectionService::Stub> db_collection_stub_;
+    std::unique_ptr<::smartbotic::database::QueryService::Stub> db_query_stub_;
+
+    // In-memory provider configs (loaded from DB)
+    mutable std::mutex mutex_;
+    std::unordered_map<std::string, provider::ProviderConfig> providers_;
+
+    // Workspace BYOK keys (workspace_id:provider_id -> key)
+    std::unordered_map<std::string, std::string> workspace_keys_;
+
+    static constexpr const char* kProvidersCollection = "__llm_providers";
+    static constexpr const char* kWorkspaceKeysCollection = "__llm_workspace_keys";
+};
+
+}  // namespace smartbotic::llm::grpc

+ 54 - 0
llm/include/smartbotic/llm/grpc/llm_service.hpp

@@ -0,0 +1,54 @@
+#pragma once
+
+#include <grpcpp/grpcpp.h>
+#include <memory>
+
+#include "llm.grpc.pb.h"
+#include "smartbotic/llm/provider/provider_factory.hpp"
+#include "smartbotic/llm/session/session_store.hpp"
+#include "smartbotic/llm/tool/tool_registry.hpp"
+
+namespace smartbotic::llm::grpc {
+
+class LLMServiceImpl final : public ::smartbotic::llm::LLMService::Service {
+public:
+    LLMServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory,
+                   std::shared_ptr<session::ISessionStore> session_store,
+                   std::shared_ptr<tool::ToolRegistry> tool_registry,
+                   const std::string& default_model);
+
+    ::grpc::Status Chat(::grpc::ServerContext* context,
+                        const ::smartbotic::llm::ChatRequest* request,
+                        ::smartbotic::llm::ChatResponse* response) override;
+
+    ::grpc::Status ChatStream(::grpc::ServerContext* context,
+                              const ::smartbotic::llm::ChatRequest* request,
+                              ::grpc::ServerWriter<::smartbotic::llm::ChatStreamChunk>* writer) override;
+
+    ::grpc::Status SubmitToolResults(::grpc::ServerContext* context,
+                                     const ::smartbotic::llm::SubmitToolResultsRequest* request,
+                                     ::smartbotic::llm::ChatResponse* response) override;
+
+    ::grpc::Status SubmitToolResultsStream(
+        ::grpc::ServerContext* context,
+        const ::smartbotic::llm::SubmitToolResultsRequest* request,
+        ::grpc::ServerWriter<::smartbotic::llm::ChatStreamChunk>* writer) override;
+
+private:
+    /// Get provider for request (handles workspace BYOK)
+    [[nodiscard]] auto GetProvider(const std::string& provider_id,
+                                   const std::string& workspace_id)
+        -> std::shared_ptr<provider::IProvider>;
+
+    /// Build chat request from proto
+    [[nodiscard]] auto BuildChatRequest(const ::smartbotic::llm::ChatRequest& request,
+                                        const session::Session& session)
+        -> provider::ChatRequest;
+
+    std::shared_ptr<provider::ProviderFactory> provider_factory_;
+    std::shared_ptr<session::ISessionStore> session_store_;
+    std::shared_ptr<tool::ToolRegistry> tool_registry_;
+    std::string default_model_;
+};
+
+}  // namespace smartbotic::llm::grpc

+ 35 - 0
llm/include/smartbotic/llm/grpc/model_service.hpp

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <grpcpp/grpcpp.h>
+#include <memory>
+
+#include "llm.grpc.pb.h"
+#include "smartbotic/llm/provider/provider_factory.hpp"
+
+namespace smartbotic::llm::grpc {
+
+class ModelServiceImpl final : public ::smartbotic::llm::ModelService::Service {
+public:
+    explicit ModelServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory);
+
+    ::grpc::Status ListProviders(::grpc::ServerContext* context,
+                                 const ::smartbotic::llm::ListProvidersRequest* request,
+                                 ::smartbotic::llm::ListProvidersResponse* response) override;
+
+    ::grpc::Status ListModels(::grpc::ServerContext* context,
+                              const ::smartbotic::llm::ListModelsRequest* request,
+                              ::smartbotic::llm::ListModelsResponse* response) override;
+
+    ::grpc::Status RefreshModels(::grpc::ServerContext* context,
+                                 const ::smartbotic::llm::RefreshModelsRequest* request,
+                                 ::smartbotic::llm::RefreshModelsResponse* response) override;
+
+    ::grpc::Status GetProviderHealth(::grpc::ServerContext* context,
+                                     const ::smartbotic::llm::GetProviderHealthRequest* request,
+                                     ::smartbotic::llm::GetProviderHealthResponse* response) override;
+
+private:
+    std::shared_ptr<provider::ProviderFactory> provider_factory_;
+};
+
+}  // namespace smartbotic::llm::grpc

+ 51 - 0
llm/include/smartbotic/llm/grpc/proto_convert.hpp

@@ -0,0 +1,51 @@
+#pragma once
+
+#include "llm.grpc.pb.h"
+
+#include "smartbotic/llm/provider/provider_interface.hpp"
+#include "smartbotic/llm/session/session.hpp"
+
+namespace smartbotic::llm::grpc {
+
+/// Convert session types to proto messages
+auto SessionToProto(const session::Session& session) -> ::smartbotic::llm::Session;
+auto SessionSummaryToProto(const session::SessionSummary& summary) -> ::smartbotic::llm::SessionSummary;
+auto MessageToProto(const session::Message& message) -> ::smartbotic::llm::Message;
+auto PageContextToProto(const session::PageContext& context) -> ::smartbotic::llm::PageContext;
+
+/// Convert proto messages to session types
+auto ProtoToSession(const ::smartbotic::llm::Session& proto) -> session::Session;
+auto ProtoToMessage(const ::smartbotic::llm::Message& proto) -> session::Message;
+auto ProtoToPageContext(const ::smartbotic::llm::PageContext& proto) -> session::PageContext;
+
+/// Convert provider types to proto messages
+auto ProviderTypeToProto(provider::ProviderType type) -> ::smartbotic::llm::ProviderType;
+auto ProtoToProviderType(::smartbotic::llm::ProviderType type) -> provider::ProviderType;
+
+auto ModelInfoToProto(const provider::ModelInfo& info) -> ::smartbotic::llm::ModelInfo;
+auto ProviderConfigToProto(const provider::ProviderConfig& config) -> ::smartbotic::llm::ProviderConfig;
+
+/// Convert message role
+auto MessageRoleToProto(session::MessageRole role) -> ::smartbotic::llm::MessageRole;
+auto ProtoToMessageRole(::smartbotic::llm::MessageRole role) -> session::MessageRole;
+
+/// Convert finish reason
+auto FinishReasonToProto(provider::FinishReason reason) -> ::smartbotic::llm::FinishReason;
+
+/// Convert usage info
+auto UsageInfoToProto(const provider::UsageInfo& usage) -> ::smartbotic::llm::UsageInfo;
+
+/// Convert tool call
+auto ToolCallToProto(const provider::ToolCall& call) -> ::smartbotic::llm::ToolCall;
+
+/// Convert content part
+auto ContentPartToProto(const session::ContentPart& part) -> ::smartbotic::llm::ContentPart;
+auto ProtoToContentPart(const ::smartbotic::llm::ContentPart& proto) -> session::ContentPart;
+
+/// Timestamp conversions
+auto TimePointToProtoTimestamp(std::chrono::system_clock::time_point tp)
+    -> ::smartbotic::llm::Timestamp;
+auto ProtoTimestampToTimePoint(const ::smartbotic::llm::Timestamp& ts)
+    -> std::chrono::system_clock::time_point;
+
+}  // namespace smartbotic::llm::grpc

+ 87 - 0
llm/include/smartbotic/llm/grpc/server.hpp

@@ -0,0 +1,87 @@
+#pragma once
+
+#include <grpcpp/grpcpp.h>
+
+#include <memory>
+#include <string>
+
+#include "smartbotic/llm/grpc/config_service.hpp"
+#include "smartbotic/llm/grpc/llm_service.hpp"
+#include "smartbotic/llm/grpc/model_service.hpp"
+#include "smartbotic/llm/grpc/session_service.hpp"
+#include "smartbotic/llm/provider/provider_factory.hpp"
+#include "smartbotic/llm/server_config.hpp"
+#include "smartbotic/llm/session/database_session_store.hpp"
+#include "smartbotic/llm/tool/tool_registry.hpp"
+
+namespace smartbotic::llm::grpc {
+
+/// Main gRPC server that hosts all LLM services
+class GrpcServer {
+public:
+    /// Create a server with the given configuration
+    explicit GrpcServer(ServerConfig config = ServerConfig::Default());
+
+    ~GrpcServer();
+
+    /// Non-copyable and non-movable
+    GrpcServer(const GrpcServer&) = delete;
+    auto operator=(const GrpcServer&) -> GrpcServer& = delete;
+    GrpcServer(GrpcServer&&) = delete;
+    auto operator=(GrpcServer&&) -> GrpcServer& = delete;
+
+    /// Start the server (initializes services and begins accepting requests)
+    /// Returns true on success, false on failure
+    [[nodiscard]] auto Start() -> bool;
+
+    /// Request server shutdown (safe to call from signal handler)
+    void RequestShutdown();
+
+    /// Wait for the server to shutdown (blocks until shutdown completes)
+    void Wait();
+
+    /// Perform final cleanup after shutdown
+    void Cleanup();
+
+    /// Stop the server gracefully (combines RequestShutdown, Wait, and Cleanup)
+    void Stop();
+
+    /// Check if the server is running
+    [[nodiscard]] auto IsRunning() const -> bool { return running_; }
+
+    /// Get the provider factory
+    [[nodiscard]] auto GetProviderFactory() -> std::shared_ptr<provider::ProviderFactory> {
+        return provider_factory_;
+    }
+
+    /// Get the session store
+    [[nodiscard]] auto GetSessionStore() -> std::shared_ptr<session::ISessionStore> {
+        return session_store_;
+    }
+
+    /// Get the tool registry
+    [[nodiscard]] auto GetToolRegistry() -> std::shared_ptr<tool::ToolRegistry> {
+        return tool_registry_;
+    }
+
+    /// Get the server configuration
+    [[nodiscard]] auto GetConfig() const -> const ServerConfig& { return config_; }
+
+private:
+    ServerConfig config_;
+    bool running_ = false;
+
+    // Core components
+    std::shared_ptr<provider::ProviderFactory> provider_factory_;
+    std::shared_ptr<session::ISessionStore> session_store_;
+    std::shared_ptr<tool::ToolRegistry> tool_registry_;
+
+    // gRPC server and services
+    std::unique_ptr<::grpc::Server> server_;
+    std::unique_ptr<LLMServiceImpl> llm_service_;
+    std::unique_ptr<SessionServiceImpl> session_service_;
+    std::unique_ptr<ModelServiceImpl> model_service_;
+    std::unique_ptr<ConfigServiceImpl> config_service_;
+};
+
+}  // namespace smartbotic::llm::grpc

+ 49 - 0
llm/include/smartbotic/llm/grpc/session_service.hpp

@@ -0,0 +1,49 @@
+#pragma once
+
+#include <grpcpp/grpcpp.h>
+#include <memory>
+
+#include "llm.grpc.pb.h"
+#include "smartbotic/llm/session/session_store.hpp"
+
+namespace smartbotic::llm::grpc {
+
+class SessionServiceImpl final : public ::smartbotic::llm::SessionService::Service {
+public:
+    explicit SessionServiceImpl(std::shared_ptr<session::ISessionStore> session_store,
+                                const std::string& default_model);
+
+    ::grpc::Status CreateSession(::grpc::ServerContext* context,
+                                 const ::smartbotic::llm::CreateSessionRequest* request,
+                                 ::smartbotic::llm::Session* response) override;
+
+    ::grpc::Status GetSession(::grpc::ServerContext* context,
+                              const ::smartbotic::llm::GetSessionRequest* request,
+                              ::smartbotic::llm::Session* response) override;
+
+    ::grpc::Status ListSessions(::grpc::ServerContext* context,
+                                const ::smartbotic::llm::ListSessionsRequest* request,
+                                ::smartbotic::llm::ListSessionsResponse* response) override;
+
+    ::grpc::Status UpdateSession(::grpc::ServerContext* context,
+                                 const ::smartbotic::llm::UpdateSessionRequest* request,
+                                 ::smartbotic::llm::Session* response) override;
+
+    ::grpc::Status DeleteSession(::grpc::ServerContext* context,
+                                 const ::smartbotic::llm::DeleteSessionRequest* request,
+                                 ::smartbotic::llm::DeleteSessionResponse* response) override;
+
+    ::grpc::Status ClearSessionMessages(::grpc::ServerContext* context,
+                                        const ::smartbotic::llm::ClearSessionMessagesRequest* request,
+                                        ::smartbotic::llm::Session* response) override;
+
+    ::grpc::Status AddMessage(::grpc::ServerContext* context,
+                              const ::smartbotic::llm::AddMessageRequest* request,
+                              ::smartbotic::llm::Session* response) override;
+
+private:
+    std::shared_ptr<session::ISessionStore> session_store_;
+    std::string default_model_;
+};
+
+}  // namespace smartbotic::llm::grpc

+ 51 - 0
llm/include/smartbotic/llm/provider/anthropic_provider.hpp

@@ -0,0 +1,51 @@
+#pragma once
+
+#include "smartbotic/llm/provider/provider_interface.hpp"
+
+#include <httplib.h>
+#include <mutex>
+
+namespace smartbotic::llm::provider {
+
+/// Anthropic-compatible provider implementation
+/// Works with Anthropic Claude API and compatible endpoints
+class AnthropicProvider : public IProvider {
+public:
+    explicit AnthropicProvider(const ProviderConfig& config);
+    ~AnthropicProvider() override = default;
+
+    // IProvider interface
+    [[nodiscard]] auto GetType() const -> ProviderType override { return ProviderType::kAnthropic; }
+    [[nodiscard]] auto GetConfig() const -> const ProviderConfig& override { return config_; }
+    void UpdateConfig(const ProviderConfig& config) override;
+
+    [[nodiscard]] auto HealthCheck() -> Result<bool> override;
+    [[nodiscard]] auto ListModels() -> Result<std::vector<ModelInfo>> override;
+    [[nodiscard]] auto Chat(const ChatRequest& request) -> Result<ChatResponse> override;
+    [[nodiscard]] auto ChatStream(const ChatRequest& request, StreamCallback callback)
+        -> Result<ChatResponse> override;
+
+private:
+    /// Build the request body JSON for messages API
+    [[nodiscard]] auto BuildMessagesRequestBody(const ChatRequest& request, bool stream) -> std::string;
+
+    /// Parse a messages API response
+    [[nodiscard]] auto ParseMessagesResponse(const std::string& body) -> Result<ChatResponse>;
+
+    /// Parse a streaming event
+    [[nodiscard]] auto ParseStreamEvent(const std::string& event_type, const std::string& data)
+        -> std::optional<StreamChunk>;
+
+    /// Create HTTP client with proper configuration
+    [[nodiscard]] auto CreateClient() -> std::unique_ptr<httplib::Client>;
+
+    /// Get authorization headers for Anthropic API
+    [[nodiscard]] auto GetHeaders() -> httplib::Headers;
+
+    ProviderConfig config_;
+    mutable std::mutex mutex_;
+
+    static constexpr const char* kAnthropicVersion = "2023-06-01";
+};
+
+}  // namespace smartbotic::llm::provider

+ 48 - 0
llm/include/smartbotic/llm/provider/openai_provider.hpp

@@ -0,0 +1,48 @@
+#pragma once
+
+#include "smartbotic/llm/provider/provider_interface.hpp"
+
+#include <httplib.h>
+#include <mutex>
+
+namespace smartbotic::llm::provider {
+
+/// OpenAI-compatible provider implementation
+/// Works with OpenAI API and any OpenAI-compatible endpoints (Ollama, vLLM, Together.ai, etc.)
+class OpenAIProvider : public IProvider {
+public:
+    explicit OpenAIProvider(const ProviderConfig& config);
+    ~OpenAIProvider() override = default;
+
+    // IProvider interface
+    [[nodiscard]] auto GetType() const -> ProviderType override { return ProviderType::kOpenAI; }
+    [[nodiscard]] auto GetConfig() const -> const ProviderConfig& override { return config_; }
+    void UpdateConfig(const ProviderConfig& config) override;
+
+    [[nodiscard]] auto HealthCheck() -> Result<bool> override;
+    [[nodiscard]] auto ListModels() -> Result<std::vector<ModelInfo>> override;
+    [[nodiscard]] auto Chat(const ChatRequest& request) -> Result<ChatResponse> override;
+    [[nodiscard]] auto ChatStream(const ChatRequest& request, StreamCallback callback)
+        -> Result<ChatResponse> override;
+
+private:
+    /// Build the request body JSON for chat completions
+    [[nodiscard]] auto BuildChatRequestBody(const ChatRequest& request, bool stream) -> std::string;
+
+    /// Parse a chat completion response
+    [[nodiscard]] auto ParseChatResponse(const std::string& body) -> Result<ChatResponse>;
+
+    /// Parse a streaming chunk (SSE data)
+    [[nodiscard]] auto ParseStreamChunk(const std::string& data) -> std::optional<StreamChunk>;
+
+    /// Create HTTP client with proper configuration
+    [[nodiscard]] auto CreateClient() -> std::unique_ptr<httplib::Client>;
+
+    /// Get authorization headers
+    [[nodiscard]] auto GetHeaders() -> httplib::Headers;
+
+    ProviderConfig config_;
+    mutable std::mutex mutex_;
+};
+
+}  // namespace smartbotic::llm::provider

+ 49 - 0
llm/include/smartbotic/llm/provider/provider_factory.hpp

@@ -0,0 +1,49 @@
+#pragma once
+
+#include "smartbotic/llm/provider/provider_interface.hpp"
+
+#include <memory>
+#include <mutex>
+#include <string>
+#include <unordered_map>
+
+namespace smartbotic::llm::provider {
+
+/// Factory for creating and managing provider instances
+class ProviderFactory {
+public:
+    ProviderFactory() = default;
+    ~ProviderFactory() = default;
+
+    // Non-copyable
+    ProviderFactory(const ProviderFactory&) = delete;
+    auto operator=(const ProviderFactory&) -> ProviderFactory& = delete;
+
+    /// Create a provider instance based on configuration
+    [[nodiscard]] static auto CreateProvider(const ProviderConfig& config)
+        -> std::unique_ptr<IProvider>;
+
+    /// Get or create a cached provider instance by ID
+    [[nodiscard]] auto GetOrCreate(const ProviderConfig& config) -> std::shared_ptr<IProvider>;
+
+    /// Get a provider by ID (returns nullptr if not found)
+    [[nodiscard]] auto Get(const std::string& provider_id) -> std::shared_ptr<IProvider>;
+
+    /// Update a provider's configuration
+    void UpdateProvider(const ProviderConfig& config);
+
+    /// Remove a provider from cache
+    void RemoveProvider(const std::string& provider_id);
+
+    /// Clear all cached providers
+    void Clear();
+
+    /// Get all cached providers
+    [[nodiscard]] auto GetAll() -> std::vector<std::shared_ptr<IProvider>>;
+
+private:
+    mutable std::mutex mutex_;
+    std::unordered_map<std::string, std::shared_ptr<IProvider>> providers_;
+};
+
+}  // namespace smartbotic::llm::provider

+ 188 - 0
llm/include/smartbotic/llm/provider/provider_interface.hpp

@@ -0,0 +1,188 @@
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::llm::provider {
+
+/// Provider types supported
+enum class ProviderType {
+    kOpenAI,
+    kAnthropic
+};
+
+/// Convert provider type to string
+[[nodiscard]] auto ProviderTypeToString(ProviderType type) -> std::string;
+
+/// Parse provider type from string
+[[nodiscard]] auto ParseProviderType(const std::string& str) -> std::optional<ProviderType>;
+
+/// Model information from the provider
+struct ModelInfo {
+    std::string id;
+    std::string name;
+    std::string owner;
+    int64_t context_length = 0;
+    bool supports_tools = false;
+    bool supports_vision = false;
+};
+
+/// Message role
+enum class MessageRole {
+    kSystem,
+    kUser,
+    kAssistant,
+    kTool
+};
+
+/// Content part in a message
+struct ContentPart {
+    enum class Type {
+        kText,
+        kImage,
+        kToolUse,
+        kToolResult
+    };
+
+    Type type = Type::kText;
+    std::string text;
+
+    // For images
+    std::string image_url;
+    std::string media_type;
+
+    // For tool use/results
+    std::string tool_id;
+    std::string tool_name;
+    std::string tool_arguments;  // JSON
+    bool is_error = false;
+};
+
+/// A chat message
+struct ChatMessage {
+    MessageRole role = MessageRole::kUser;
+    std::vector<ContentPart> content;
+};
+
+/// Tool definition for function calling
+struct ToolDefinition {
+    std::string name;
+    std::string description;
+    std::string input_schema;  // JSON Schema
+};
+
+/// Tool call requested by the model
+struct ToolCall {
+    std::string id;
+    std::string name;
+    std::string arguments;  // JSON
+};
+
+/// Chat request to the provider
+struct ChatRequest {
+    std::string model;
+    std::vector<ChatMessage> messages;
+    std::optional<std::string> system_prompt;
+    std::vector<ToolDefinition> tools;
+
+    // Parameters
+    float temperature = 1.0f;
+    int max_tokens = 4096;
+};
+
+/// Usage information
+struct UsageInfo {
+    int prompt_tokens = 0;
+    int completion_tokens = 0;
+    int total_tokens = 0;
+};
+
+/// Finish reason for completion
+enum class FinishReason {
+    kStop,
+    kLength,
+    kToolUse,
+    kContentFilter,
+    kError
+};
+
+/// Chat response from the provider
+struct ChatResponse {
+    std::string content;
+    std::vector<ToolCall> tool_calls;
+    UsageInfo usage;
+    FinishReason finish_reason = FinishReason::kStop;
+    std::string error;  // Set if there was an error
+};
+
+/// Stream chunk during streaming
+struct StreamChunk {
+    std::string content_delta;
+    std::optional<ToolCall> tool_call;
+    bool is_done = false;
+    std::optional<UsageInfo> usage;       // Present in final chunk
+    std::optional<FinishReason> finish_reason;
+};
+
+/// Callback for streaming responses
+using StreamCallback = std::function<bool(const StreamChunk& chunk)>;
+
+/// Result wrapper for provider operations
+template <typename T>
+struct Result {
+    bool success = false;
+    std::string error;
+    T value;
+
+    static auto Ok(T val) -> Result {
+        return Result{true, "", std::move(val)};
+    }
+
+    static auto Error(const std::string& msg) -> Result {
+        return Result{false, msg, T{}};
+    }
+};
+
+/// Provider configuration
+struct ProviderConfig {
+    std::string id;
+    std::string name;
+    ProviderType type = ProviderType::kOpenAI;
+    std::string base_url;
+    std::string api_key;
+    bool enabled = true;
+};
+
+/// Provider interface - base class for all LLM providers
+class IProvider {
+public:
+    virtual ~IProvider() = default;
+
+    /// Get the provider type
+    [[nodiscard]] virtual auto GetType() const -> ProviderType = 0;
+
+    /// Get the provider configuration
+    [[nodiscard]] virtual auto GetConfig() const -> const ProviderConfig& = 0;
+
+    /// Update the provider configuration (e.g., when API key changes)
+    virtual void UpdateConfig(const ProviderConfig& config) = 0;
+
+    /// Check if the provider is healthy/reachable
+    [[nodiscard]] virtual auto HealthCheck() -> Result<bool> = 0;
+
+    /// List available models from this provider
+    [[nodiscard]] virtual auto ListModels() -> Result<std::vector<ModelInfo>> = 0;
+
+    /// Send a chat request and get a complete response
+    [[nodiscard]] virtual auto Chat(const ChatRequest& request) -> Result<ChatResponse> = 0;
+
+    /// Send a chat request with streaming response
+    /// Returns the final complete response after streaming completes
+    [[nodiscard]] virtual auto ChatStream(const ChatRequest& request, StreamCallback callback)
+        -> Result<ChatResponse> = 0;
+};
+
+}  // namespace smartbotic::llm::provider

+ 38 - 0
llm/include/smartbotic/llm/server_config.hpp

@@ -0,0 +1,38 @@
+#pragma once
+
+#include "smartbotic/llm/config.hpp"
+
+#include <cstdint>
+#include <string>
+
+namespace smartbotic::llm {
+
+/// Server configuration for the LLM gRPC server
+struct ServerConfig {
+    std::string address = "0.0.0.0";
+    uint16_t port = 50052;
+    std::string database_address = "127.0.0.1:50051";
+    std::string default_model = "gpt-4o";
+    int64_t session_timeout_seconds = 86400;
+    LogLevel log_level = LogLevel::kInfo;
+
+    [[nodiscard]] auto GetListenAddress() const -> std::string {
+        return address + ":" + std::to_string(port);
+    }
+
+    [[nodiscard]] static auto Default() -> ServerConfig { return ServerConfig{}; }
+
+    /// Create from LlmConfig
+    [[nodiscard]] static auto FromConfig(const LlmConfig& config) -> ServerConfig {
+        ServerConfig server_config;
+        server_config.address = config.address;
+        server_config.port = config.port;
+        server_config.database_address = config.database_address;
+        server_config.default_model = config.default_model;
+        server_config.session_timeout_seconds = config.session_timeout_seconds;
+        server_config.log_level = config.log_level;
+        return server_config;
+    }
+};
+
+}  // namespace smartbotic::llm

+ 78 - 0
llm/include/smartbotic/llm/session/database_session_store.hpp

@@ -0,0 +1,78 @@
+#pragma once
+
+#include "smartbotic/llm/session/session_store.hpp"
+
+#include <grpcpp/grpcpp.h>
+#include "database.grpc.pb.h"
+
+#include <memory>
+#include <string>
+
+namespace smartbotic::llm::session {
+
+/// Configuration for database session store
+struct DatabaseSessionStoreConfig {
+    std::string database_address = "127.0.0.1:50051";
+    std::string collection_name = "__llm_sessions";
+    std::string default_model = "gpt-4o";
+};
+
+/// Session store implementation using the database node
+class DatabaseSessionStore : public ISessionStore {
+public:
+    explicit DatabaseSessionStore(const DatabaseSessionStoreConfig& config);
+    ~DatabaseSessionStore() override = default;
+
+    // ISessionStore interface
+    [[nodiscard]] auto Initialize() -> bool override;
+
+    [[nodiscard]] auto CreateSession(const CreateSessionRequest& request)
+        -> Result<Session> override;
+
+    [[nodiscard]] auto GetSession(const std::string& session_id,
+                                  const std::string& user_id,
+                                  bool include_messages = true) -> Result<Session> override;
+
+    [[nodiscard]] auto ListSessions(const ListSessionsRequest& request)
+        -> Result<ListSessionsResponse> override;
+
+    [[nodiscard]] auto UpdateSession(const UpdateSessionRequest& request)
+        -> Result<Session> override;
+
+    [[nodiscard]] auto DeleteSession(const std::string& session_id,
+                                     const std::string& user_id) -> Result<bool> override;
+
+    [[nodiscard]] auto AddMessage(const std::string& session_id,
+                                  const std::string& user_id,
+                                  const Message& message) -> Result<Session> override;
+
+    [[nodiscard]] auto ClearMessages(const std::string& session_id,
+                                     const std::string& user_id) -> Result<Session> override;
+
+private:
+    /// Connect to the database node
+    [[nodiscard]] auto Connect() -> bool;
+
+    /// Convert a Session to database document format (MapValue)
+    [[nodiscard]] auto SessionToDocument(const Session& session)
+        -> ::smartbotic::database::MapValue;
+
+    /// Convert a database document (MapValue) to Session
+    [[nodiscard]] auto DocumentToSession(const ::smartbotic::database::MapValue& data,
+                                         const std::string& doc_id) -> Session;
+
+    /// Convert a Message to document format
+    [[nodiscard]] auto MessageToValue(const Message& message) -> ::smartbotic::database::Value;
+
+    /// Convert document value to Message
+    [[nodiscard]] auto ValueToMessage(const ::smartbotic::database::Value& value) -> Message;
+
+    DatabaseSessionStoreConfig config_;
+    std::shared_ptr<::grpc::Channel> channel_;
+    std::unique_ptr<::smartbotic::database::DocumentService::Stub> document_stub_;
+    std::unique_ptr<::smartbotic::database::CollectionService::Stub> collection_stub_;
+    std::unique_ptr<::smartbotic::database::QueryService::Stub> query_stub_;
+    bool initialized_ = false;
+};
+
+}  // namespace smartbotic::llm::session

+ 104 - 0
llm/include/smartbotic/llm/session/session.hpp

@@ -0,0 +1,104 @@
+#pragma once
+
+#include <chrono>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::llm::session {
+
+/// Page context when a message was sent
+struct PageContext {
+    std::string path;
+    std::string title;
+    std::string workspace_id;
+    std::string collection;
+    std::string view_id;
+};
+
+/// Message role
+enum class MessageRole {
+    kSystem,
+    kUser,
+    kAssistant,
+    kTool
+};
+
+/// Content part type
+enum class ContentPartType {
+    kText,
+    kImage,
+    kToolUse,
+    kToolResult
+};
+
+/// A content part in a message
+struct ContentPart {
+    ContentPartType type = ContentPartType::kText;
+    std::string text;
+
+    // For images
+    std::string image_url;
+    std::string media_type;
+
+    // For tool use/results
+    std::string tool_id;
+    std::string tool_name;
+    std::string tool_arguments;  // JSON
+    bool is_error = false;
+};
+
+/// A chat message
+struct Message {
+    std::string id;
+    MessageRole role = MessageRole::kUser;
+    std::vector<ContentPart> content;
+    std::chrono::system_clock::time_point created_at;
+    std::optional<PageContext> page_context;
+};
+
+/// A chat session
+struct Session {
+    std::string id;
+    std::string user_id;
+    std::string workspace_id;
+    std::string title;
+    std::string model_id;
+    std::string system_prompt;
+    std::vector<Message> messages;
+    std::chrono::system_clock::time_point created_at;
+    std::chrono::system_clock::time_point updated_at;
+};
+
+/// Session summary (for listing)
+struct SessionSummary {
+    std::string id;
+    std::string user_id;
+    std::string workspace_id;
+    std::string title;
+    std::string model_id;
+    int message_count = 0;
+    std::chrono::system_clock::time_point created_at;
+    std::chrono::system_clock::time_point updated_at;
+};
+
+/// Convert message role to string
+[[nodiscard]] auto MessageRoleToString(MessageRole role) -> std::string;
+
+/// Parse message role from string
+[[nodiscard]] auto ParseMessageRole(const std::string& str) -> std::optional<MessageRole>;
+
+/// Generate a unique ID for sessions/messages
+[[nodiscard]] auto GenerateId() -> std::string;
+
+/// Get current timestamp
+[[nodiscard]] auto Now() -> std::chrono::system_clock::time_point;
+
+/// Convert timestamp to ISO8601 string
+[[nodiscard]] auto TimestampToString(std::chrono::system_clock::time_point tp) -> std::string;
+
+/// Parse ISO8601 string to timestamp
+[[nodiscard]] auto ParseTimestamp(const std::string& str)
+    -> std::optional<std::chrono::system_clock::time_point>;
+
+}  // namespace smartbotic::llm::session

+ 99 - 0
llm/include/smartbotic/llm/session/session_store.hpp

@@ -0,0 +1,99 @@
+#pragma once
+
+#include "smartbotic/llm/session/session.hpp"
+
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::llm::session {
+
+/// Result type for session store operations
+template <typename T>
+struct Result {
+    bool success = false;
+    std::string error;
+    T value;
+
+    static auto Ok(T val) -> Result {
+        return Result{true, "", std::move(val)};
+    }
+
+    static auto Error(const std::string& msg) -> Result {
+        return Result{false, msg, T{}};
+    }
+};
+
+/// Request to create a session
+struct CreateSessionRequest {
+    std::string user_id;
+    std::string workspace_id;
+    std::string title;        // Optional, auto-generated if empty
+    std::string model_id;     // Optional, uses default if empty
+    std::string system_prompt;
+};
+
+/// Request to update a session
+struct UpdateSessionRequest {
+    std::string session_id;
+    std::string user_id;
+    std::optional<std::string> title;
+    std::optional<std::string> model_id;
+    std::optional<std::string> system_prompt;
+};
+
+/// Request to list sessions
+struct ListSessionsRequest {
+    std::string user_id;
+    std::string workspace_id;  // Optional filter
+    int page_size = 50;
+    std::string page_token;
+};
+
+/// Response for listing sessions
+struct ListSessionsResponse {
+    std::vector<SessionSummary> sessions;
+    std::string next_page_token;
+    int total_count = 0;
+};
+
+/// Interface for session storage
+class ISessionStore {
+public:
+    virtual ~ISessionStore() = default;
+
+    /// Initialize the store (create collections, indexes, etc.)
+    [[nodiscard]] virtual auto Initialize() -> bool = 0;
+
+    /// Create a new session
+    [[nodiscard]] virtual auto CreateSession(const CreateSessionRequest& request)
+        -> Result<Session> = 0;
+
+    /// Get a session by ID (with optional message loading)
+    [[nodiscard]] virtual auto GetSession(const std::string& session_id,
+                                          const std::string& user_id,
+                                          bool include_messages = true) -> Result<Session> = 0;
+
+    /// List sessions for a user
+    [[nodiscard]] virtual auto ListSessions(const ListSessionsRequest& request)
+        -> Result<ListSessionsResponse> = 0;
+
+    /// Update a session
+    [[nodiscard]] virtual auto UpdateSession(const UpdateSessionRequest& request)
+        -> Result<Session> = 0;
+
+    /// Delete a session
+    [[nodiscard]] virtual auto DeleteSession(const std::string& session_id,
+                                             const std::string& user_id) -> Result<bool> = 0;
+
+    /// Add a message to a session
+    [[nodiscard]] virtual auto AddMessage(const std::string& session_id,
+                                          const std::string& user_id,
+                                          const Message& message) -> Result<Session> = 0;
+
+    /// Clear all messages in a session
+    [[nodiscard]] virtual auto ClearMessages(const std::string& session_id,
+                                             const std::string& user_id) -> Result<Session> = 0;
+};
+
+}  // namespace smartbotic::llm::session

+ 38 - 0
llm/include/smartbotic/llm/tool/tool_definition.hpp

@@ -0,0 +1,38 @@
+#pragma once
+
+#include <functional>
+#include <optional>
+#include <string>
+
+namespace smartbotic::llm::tool {
+
+/// Definition of a tool that can be used by the LLM
+struct ToolDefinition {
+    std::string name;
+    std::string description;
+    std::string input_schema;  // JSON Schema
+
+    /// Optional workspace ID - if set, tool is only available in that workspace
+    std::optional<std::string> workspace_id;
+};
+
+/// Result of tool execution
+struct ToolExecutionResult {
+    bool success = false;
+    std::string content;
+    bool is_error = false;
+};
+
+/// Context provided to tool executors
+struct ToolExecutionContext {
+    std::string user_id;
+    std::string workspace_id;
+    std::string session_id;
+};
+
+/// Function type for tool executors
+using ToolExecutor = std::function<ToolExecutionResult(
+    const std::string& arguments,  // JSON arguments
+    const ToolExecutionContext& context)>;
+
+}  // namespace smartbotic::llm::tool

+ 59 - 0
llm/include/smartbotic/llm/tool/tool_registry.hpp

@@ -0,0 +1,59 @@
+#pragma once
+
+#include "smartbotic/llm/tool/tool_definition.hpp"
+
+#include <mutex>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+namespace smartbotic::llm::tool {
+
+/// Registry for tools available to the LLM
+class ToolRegistry {
+public:
+    ToolRegistry() = default;
+    ~ToolRegistry() = default;
+
+    // Non-copyable
+    ToolRegistry(const ToolRegistry&) = delete;
+    auto operator=(const ToolRegistry&) -> ToolRegistry& = delete;
+
+    /// Register a tool definition (no executor - for external tools)
+    void RegisterTool(const ToolDefinition& definition);
+
+    /// Register a tool with an executor (for built-in tools)
+    void RegisterTool(const ToolDefinition& definition, ToolExecutor executor);
+
+    /// Unregister a tool by name
+    void UnregisterTool(const std::string& name);
+
+    /// Get all tools available for a workspace (includes global tools)
+    [[nodiscard]] auto GetToolsForWorkspace(const std::string& workspace_id)
+        -> std::vector<ToolDefinition>;
+
+    /// Get all registered tools
+    [[nodiscard]] auto GetAllTools() -> std::vector<ToolDefinition>;
+
+    /// Check if a tool has an executor
+    [[nodiscard]] auto HasExecutor(const std::string& name) const -> bool;
+
+    /// Execute a tool (returns nullopt if tool doesn't exist or has no executor)
+    [[nodiscard]] auto Execute(const std::string& name,
+                               const std::string& arguments,
+                               const ToolExecutionContext& context) -> std::optional<ToolExecutionResult>;
+
+    /// Clear all registered tools
+    void Clear();
+
+private:
+    struct RegisteredTool {
+        ToolDefinition definition;
+        std::optional<ToolExecutor> executor;
+    };
+
+    mutable std::mutex mutex_;
+    std::unordered_map<std::string, RegisteredTool> tools_;
+};
+
+}  // namespace smartbotic::llm::tool

+ 415 - 0
llm/src/config.cpp

@@ -0,0 +1,415 @@
+#include "smartbotic/llm/config.hpp"
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include <algorithm>
+#include <cctype>
+#include <cstdlib>
+#include <filesystem>
+#include <fstream>
+
+namespace smartbotic::llm {
+
+auto LogLevelToString(LogLevel level) -> std::string {
+    switch (level) {
+        case LogLevel::kTrace:
+            return "trace";
+        case LogLevel::kDebug:
+            return "debug";
+        case LogLevel::kInfo:
+            return "info";
+        case LogLevel::kWarn:
+            return "warn";
+        case LogLevel::kError:
+            return "error";
+        case LogLevel::kCritical:
+            return "critical";
+        case LogLevel::kOff:
+            return "off";
+    }
+    return "info";
+}
+
+auto ParseLogLevel(const std::string& str) -> std::optional<LogLevel> {
+    std::string lower;
+    lower.reserve(str.size());
+    std::transform(str.begin(), str.end(), std::back_inserter(lower),
+                   [](unsigned char c) { return std::tolower(c); });
+
+    if (lower == "trace") return LogLevel::kTrace;
+    if (lower == "debug") return LogLevel::kDebug;
+    if (lower == "info") return LogLevel::kInfo;
+    if (lower == "warn" || lower == "warning") return LogLevel::kWarn;
+    if (lower == "error" || lower == "err") return LogLevel::kError;
+    if (lower == "critical" || lower == "crit" || lower == "fatal") return LogLevel::kCritical;
+    if (lower == "off" || lower == "none") return LogLevel::kOff;
+
+    return std::nullopt;
+}
+
+namespace {
+
+auto GetEnvOrDefault(const char* name, const std::string& default_value) -> std::string {
+    const char* value = std::getenv(name);
+    return value != nullptr ? std::string(value) : default_value;
+}
+
+auto GetEnvOrDefault(const char* name, uint16_t default_value) -> uint16_t {
+    const char* value = std::getenv(name);
+    if (value == nullptr) {
+        return default_value;
+    }
+    try {
+        return static_cast<uint16_t>(std::stoi(value));
+    } catch (...) {
+        return default_value;
+    }
+}
+
+auto GetEnvOrDefault(const char* name, int64_t default_value) -> int64_t {
+    const char* value = std::getenv(name);
+    if (value == nullptr) {
+        return default_value;
+    }
+    try {
+        return std::stoll(value);
+    } catch (...) {
+        return default_value;
+    }
+}
+
+}  // namespace
+
+auto ConfigLoader::GetDefaultConfigPaths() -> std::vector<std::string> {
+    std::vector<std::string> paths;
+
+    // Current directory
+    paths.emplace_back("./smartbotic-llm.json");
+
+    // Config subdirectory
+    paths.emplace_back("./config/smartbotic-llm.json");
+
+    // /etc for system-wide config
+    paths.emplace_back("/etc/smartbotic/smartbotic-llm.json");
+
+    // Home directory
+    const char* home = std::getenv("HOME");
+    if (home != nullptr) {
+        paths.push_back(std::string(home) + "/.config/smartbotic/smartbotic-llm.json");
+    }
+
+    return paths;
+}
+
+auto ConfigLoader::FindConfigFile() -> std::optional<std::string> {
+    // Check environment variable for explicit config path
+    const char* config_path_env = std::getenv("SMARTBOTIC_LLM_CONFIG");
+    if (config_path_env != nullptr && std::filesystem::exists(config_path_env)) {
+        return std::string(config_path_env);
+    }
+
+    // Search default paths
+    for (const auto& path : GetDefaultConfigPaths()) {
+        if (std::filesystem::exists(path)) {
+            return path;
+        }
+    }
+
+    return std::nullopt;
+}
+
+auto ConfigLoader::LoadFromFile(const std::string& path) -> std::optional<LlmConfig> {
+    try {
+        if (!std::filesystem::exists(path)) {
+            spdlog::warn("Config file not found: {}", path);
+            return std::nullopt;
+        }
+
+        std::ifstream file(path);
+        if (!file.is_open()) {
+            spdlog::error("Failed to open config file: {}", path);
+            return std::nullopt;
+        }
+
+        nlohmann::json json = nlohmann::json::parse(file);
+        LlmConfig config;
+
+        // Server settings (flat structure for simplicity)
+        if (json.contains("address")) {
+            config.address = json["address"].get<std::string>();
+        }
+        if (json.contains("port")) {
+            config.port = json["port"].get<uint16_t>();
+        }
+
+        // Database connection
+        if (json.contains("database_address")) {
+            config.database_address = json["database_address"].get<std::string>();
+        }
+
+        // LLM settings
+        if (json.contains("default_model")) {
+            config.default_model = json["default_model"].get<std::string>();
+        }
+        if (json.contains("session_timeout_seconds")) {
+            config.session_timeout_seconds = json["session_timeout_seconds"].get<int64_t>();
+        }
+
+        // Logging
+        if (json.contains("log_level")) {
+            auto level_str = json["log_level"].get<std::string>();
+            auto level = ParseLogLevel(level_str);
+            if (level) {
+                config.log_level = *level;
+            } else {
+                spdlog::warn("Invalid log level '{}', using default", level_str);
+            }
+        }
+
+        spdlog::info("Loaded configuration from: {}", path);
+        return config;
+
+    } catch (const nlohmann::json::exception& e) {
+        spdlog::error("Failed to parse config file {}: {}", path, e.what());
+        return std::nullopt;
+    } catch (const std::exception& e) {
+        spdlog::error("Error loading config file {}: {}", path, e.what());
+        return std::nullopt;
+    }
+}
+
+void ConfigLoader::ApplyEnvironment(LlmConfig& config) {
+    // Server settings from environment
+    config.address = GetEnvOrDefault("SMARTBOTIC_LLM_ADDRESS", config.address);
+    config.port = GetEnvOrDefault("SMARTBOTIC_LLM_PORT", config.port);
+    config.database_address = GetEnvOrDefault("SMARTBOTIC_LLM_DATABASE_ADDRESS", config.database_address);
+
+    // LLM settings from environment
+    config.default_model = GetEnvOrDefault("SMARTBOTIC_LLM_DEFAULT_MODEL", config.default_model);
+    config.session_timeout_seconds =
+        GetEnvOrDefault("SMARTBOTIC_LLM_SESSION_TIMEOUT", config.session_timeout_seconds);
+
+    // Log level from environment
+    const char* log_level_env = std::getenv("SMARTBOTIC_LLM_LOG_LEVEL");
+    if (log_level_env != nullptr) {
+        auto level = ParseLogLevel(log_level_env);
+        if (level) {
+            config.log_level = *level;
+        }
+    }
+}
+
+auto ConfigLoader::ParseCommandLine(int argc, char* argv[], LlmConfig& config)
+    -> std::optional<bool> {
+    std::string config_file_override;
+
+    for (int i = 1; i < argc; ++i) {
+        std::string arg = argv[i];
+
+        if (arg == "--help" || arg == "-h") {
+            PrintHelp(argv[0]);
+            return std::nullopt;
+        }
+
+        if (arg == "--version" || arg == "-v") {
+            spdlog::info("SmartBotic LLM Service v0.1.0");
+            return std::nullopt;
+        }
+
+        if (arg == "--config" || arg == "-c") {
+            if (i + 1 < argc) {
+                config_file_override = argv[++i];
+            } else {
+                spdlog::error("--config requires a path argument");
+                return false;
+            }
+            continue;
+        }
+
+        if (arg == "--port" || arg == "-p") {
+            if (i + 1 < argc) {
+                try {
+                    config.port = static_cast<uint16_t>(std::stoi(argv[++i]));
+                } catch (...) {
+                    spdlog::error("Invalid port number: {}", argv[i]);
+                    return false;
+                }
+            } else {
+                spdlog::error("--port requires a number argument");
+                return false;
+            }
+            continue;
+        }
+
+        if (arg == "--address" || arg == "-a") {
+            if (i + 1 < argc) {
+                config.address = argv[++i];
+            } else {
+                spdlog::error("--address requires an address argument");
+                return false;
+            }
+            continue;
+        }
+
+        if (arg == "--database-address" || arg == "-d") {
+            if (i + 1 < argc) {
+                config.database_address = argv[++i];
+            } else {
+                spdlog::error("--database-address requires an address argument");
+                return false;
+            }
+            continue;
+        }
+
+        if (arg == "--default-model") {
+            if (i + 1 < argc) {
+                config.default_model = argv[++i];
+            } else {
+                spdlog::error("--default-model requires a model name argument");
+                return false;
+            }
+            continue;
+        }
+
+        if (arg == "--session-timeout") {
+            if (i + 1 < argc) {
+                try {
+                    config.session_timeout_seconds = std::stoll(argv[++i]);
+                } catch (...) {
+                    spdlog::error("Invalid session timeout: {}", argv[i]);
+                    return false;
+                }
+            } else {
+                spdlog::error("--session-timeout requires a number argument");
+                return false;
+            }
+            continue;
+        }
+
+        if (arg == "--log-level" || arg == "-l") {
+            if (i + 1 < argc) {
+                auto level = ParseLogLevel(argv[++i]);
+                if (level) {
+                    config.log_level = *level;
+                } else {
+                    spdlog::error("Invalid log level: {}", argv[i]);
+                    return false;
+                }
+            } else {
+                spdlog::error("--log-level requires a level argument");
+                return false;
+            }
+            continue;
+        }
+
+        spdlog::error("Unknown argument: {}", arg);
+        PrintHelp(argv[0]);
+        return false;
+    }
+
+    // If config file was specified on command line, load it
+    if (!config_file_override.empty()) {
+        auto file_config = LoadFromFile(config_file_override);
+        if (!file_config) {
+            spdlog::error("Failed to load specified config file: {}", config_file_override);
+            return false;
+        }
+        config = *file_config;
+
+        // Re-parse command line to override file settings
+        for (int i = 1; i < argc; ++i) {
+            std::string arg = argv[i];
+            if (arg == "--config" || arg == "-c") {
+                ++i;
+                continue;
+            }
+            if ((arg == "--port" || arg == "-p") && i + 1 < argc) {
+                config.port = static_cast<uint16_t>(std::stoi(argv[++i]));
+            } else if ((arg == "--address" || arg == "-a") && i + 1 < argc) {
+                config.address = argv[++i];
+            } else if ((arg == "--database-address" || arg == "-d") && i + 1 < argc) {
+                config.database_address = argv[++i];
+            } else if (arg == "--default-model" && i + 1 < argc) {
+                config.default_model = argv[++i];
+            } else if (arg == "--session-timeout" && i + 1 < argc) {
+                config.session_timeout_seconds = std::stoll(argv[++i]);
+            } else if ((arg == "--log-level" || arg == "-l") && i + 1 < argc) {
+                auto level = ParseLogLevel(argv[++i]);
+                if (level) config.log_level = *level;
+            }
+        }
+    }
+
+    return true;
+}
+
+auto ConfigLoader::Load(int argc, char* argv[]) -> LlmConfig {
+    LlmConfig config = LlmConfig::Default();
+
+    // 1. Try to find and load config file
+    auto config_file = FindConfigFile();
+    if (config_file) {
+        auto file_config = LoadFromFile(*config_file);
+        if (file_config) {
+            config = *file_config;
+        }
+    }
+
+    // 2. Apply environment variables
+    ApplyEnvironment(config);
+
+    // 3. Parse command-line arguments (highest priority)
+    auto result = ParseCommandLine(argc, argv, config);
+    if (!result.has_value()) {
+        std::exit(0);
+    }
+    if (!result.value()) {
+        std::exit(1);
+    }
+
+    return config;
+}
+
+void ConfigLoader::PrintHelp(const char* program_name) {
+    spdlog::info("Usage: {} [options]", program_name);
+    spdlog::info("");
+    spdlog::info("SmartBotic LLM Service");
+    spdlog::info("");
+    spdlog::info("Options:");
+    spdlog::info("  -h, --help                   Show this help message");
+    spdlog::info("  -v, --version                Show version information");
+    spdlog::info("  -c, --config <path>          Path to config file (default: smartbotic-llm.json)");
+    spdlog::info("  -p, --port <port>            Port to listen on (default: 50052)");
+    spdlog::info("  -a, --address <addr>         Address to bind to (default: 0.0.0.0)");
+    spdlog::info("  -d, --database-address <addr>  Database node address (default: 127.0.0.1:50051)");
+    spdlog::info("  --default-model <model>      Default LLM model (default: gpt-4o)");
+    spdlog::info("  --session-timeout <sec>      Session timeout in seconds (default: 86400)");
+    spdlog::info("  -l, --log-level <level>      Log level: trace, debug, info, warn, error, critical, off");
+    spdlog::info("");
+    spdlog::info("Environment Variables:");
+    spdlog::info("  SMARTBOTIC_LLM_CONFIG            Path to config file");
+    spdlog::info("  SMARTBOTIC_LLM_ADDRESS           Address to bind to");
+    spdlog::info("  SMARTBOTIC_LLM_PORT              Port to listen on");
+    spdlog::info("  SMARTBOTIC_LLM_DATABASE_ADDRESS  Database node address");
+    spdlog::info("  SMARTBOTIC_LLM_DEFAULT_MODEL     Default LLM model");
+    spdlog::info("  SMARTBOTIC_LLM_SESSION_TIMEOUT   Session timeout in seconds");
+    spdlog::info("  SMARTBOTIC_LLM_LOG_LEVEL         Log level");
+    spdlog::info("");
+    spdlog::info("Config File Search Order:");
+    for (const auto& path : GetDefaultConfigPaths()) {
+        spdlog::info("  {}", path);
+    }
+}
+
+void ConfigLoader::PrintConfig(const LlmConfig& config) {
+    spdlog::info("Configuration:");
+    spdlog::info("  Address:          {}", config.address);
+    spdlog::info("  Port:             {}", config.port);
+    spdlog::info("  Database Address: {}", config.database_address);
+    spdlog::info("  Default Model:    {}", config.default_model);
+    spdlog::info("  Session Timeout:  {} seconds", config.session_timeout_seconds);
+    spdlog::info("  Log Level:        {}", LogLevelToString(config.log_level));
+}
+
+}  // namespace smartbotic::llm

+ 393 - 0
llm/src/grpc/config_service.cpp

@@ -0,0 +1,393 @@
+#include "smartbotic/llm/grpc/config_service.hpp"
+
+#include <nlohmann/json.hpp>
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/llm/grpc/proto_convert.hpp"
+
+namespace smartbotic::llm::grpc {
+
+ConfigServiceImpl::ConfigServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory,
+                                     const std::string& database_address)
+    : provider_factory_(std::move(provider_factory)), database_address_(database_address) {}
+
+auto ConfigServiceImpl::ConnectToDatabase() -> bool {
+    if (db_channel_) {
+        auto state = db_channel_->GetState(false);
+        if (state == GRPC_CHANNEL_READY || state == GRPC_CHANNEL_IDLE) {
+            return true;
+        }
+    }
+
+    spdlog::info("Connecting to database at {}", database_address_);
+    db_channel_ = ::grpc::CreateChannel(database_address_, ::grpc::InsecureChannelCredentials());
+
+    auto deadline = std::chrono::system_clock::now() + std::chrono::seconds(10);
+    if (!db_channel_->WaitForConnected(deadline)) {
+        spdlog::error("Failed to connect to database for config service");
+        return false;
+    }
+
+    db_document_stub_ = ::smartbotic::database::DocumentService::NewStub(db_channel_);
+    db_collection_stub_ = ::smartbotic::database::CollectionService::NewStub(db_channel_);
+    db_query_stub_ = ::smartbotic::database::QueryService::NewStub(db_channel_);
+
+    return true;
+}
+
+auto ConfigServiceImpl::Initialize() -> bool {
+    if (!ConnectToDatabase()) {
+        return false;
+    }
+
+    // Ensure collections exist
+    {
+        ::grpc::ClientContext ctx;
+        ::smartbotic::database::CreateCollectionRequest request;
+        request.set_name(kProvidersCollection);
+        ::smartbotic::database::CollectionMetadata response;
+        db_collection_stub_->CreateCollection(&ctx, request, &response);
+    }
+
+    {
+        ::grpc::ClientContext ctx;
+        ::smartbotic::database::CreateCollectionRequest request;
+        request.set_name(kWorkspaceKeysCollection);
+        ::smartbotic::database::CollectionMetadata response;
+        db_collection_stub_->CreateCollection(&ctx, request, &response);
+    }
+
+    // Load existing providers
+    return LoadProviders();
+}
+
+auto ConfigServiceImpl::LoadProviders() -> bool {
+    ::grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest request;
+    request.set_collection(kProvidersCollection);
+    request.set_limit(100);
+
+    ::smartbotic::database::QueryResponse response;
+    auto status = db_query_stub_->Query(&ctx, request, &response);
+
+    if (!status.ok()) {
+        spdlog::warn("Failed to load providers: {}", status.error_message());
+        return false;
+    }
+
+    std::lock_guard<std::mutex> lock(mutex_);
+    providers_.clear();
+
+    for (const auto& doc : response.documents()) {
+        provider::ProviderConfig config;
+        config.id = doc.id();
+
+        const auto& fields = doc.data().fields();
+        if (fields.contains("name") && fields.at("name").has_string_value()) {
+            config.name = fields.at("name").string_value();
+        }
+        if (fields.contains("type") && fields.at("type").has_string_value()) {
+            auto type = provider::ParseProviderType(fields.at("type").string_value());
+            if (type) config.type = *type;
+        }
+        if (fields.contains("base_url") && fields.at("base_url").has_string_value()) {
+            config.base_url = fields.at("base_url").string_value();
+        }
+        if (fields.contains("api_key_encrypted") && fields.at("api_key_encrypted").has_string_value()) {
+            config.api_key = DecryptApiKey(fields.at("api_key_encrypted").string_value());
+        }
+        if (fields.contains("enabled") && fields.at("enabled").has_bool_value()) {
+            config.enabled = fields.at("enabled").bool_value();
+        }
+
+        providers_[config.id] = config;
+
+        // Create provider instance
+        if (config.enabled) {
+            (void)provider_factory_->GetOrCreate(config);
+        }
+    }
+
+    spdlog::info("Loaded {} providers", providers_.size());
+    return true;
+}
+
+auto ConfigServiceImpl::SaveProvider(const provider::ProviderConfig& config) -> bool {
+    if (!ConnectToDatabase()) {
+        return false;
+    }
+
+    ::grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest request;
+    request.set_collection(kProvidersCollection);
+    request.set_id(config.id);
+    request.set_merge(false);
+
+    auto* fields = request.mutable_data()->mutable_fields();
+    (*fields)["name"].set_string_value(config.name);
+    (*fields)["type"].set_string_value(provider::ProviderTypeToString(config.type));
+    (*fields)["base_url"].set_string_value(config.base_url);
+    (*fields)["enabled"].set_bool_value(config.enabled);
+
+    if (!config.api_key.empty()) {
+        (*fields)["api_key_encrypted"].set_string_value(EncryptApiKey(config.api_key));
+    }
+
+    ::smartbotic::database::Document response;
+    auto status = db_document_stub_->UpdateDocument(&ctx, request, &response);
+
+    if (status.error_code() == ::grpc::StatusCode::NOT_FOUND) {
+        // Create new document
+        ::grpc::ClientContext create_ctx;
+        ::smartbotic::database::CreateDocumentRequest create_request;
+        create_request.set_collection(kProvidersCollection);
+        create_request.set_id(config.id);
+        *create_request.mutable_data() = request.data();
+
+        status = db_document_stub_->CreateDocument(&create_ctx, create_request, &response);
+    }
+
+    return status.ok();
+}
+
+auto ConfigServiceImpl::DeleteProvider(const std::string& provider_id) -> bool {
+    if (!ConnectToDatabase()) {
+        return false;
+    }
+
+    ::grpc::ClientContext ctx;
+    ::smartbotic::database::DeleteDocumentRequest request;
+    request.set_collection(kProvidersCollection);
+    request.set_id(provider_id);
+
+    ::smartbotic::database::DeleteDocumentResponse response;
+    auto status = db_document_stub_->DeleteDocument(&ctx, request, &response);
+
+    return status.ok();
+}
+
+auto ConfigServiceImpl::EncryptApiKey(const std::string& key) -> std::string {
+    // Simple base64 encoding for now (should use proper encryption in production)
+    // In production, use a proper encryption key stored securely
+    static const std::string base64_chars =
+        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+    std::string encoded;
+    int val = 0;
+    int valb = -6;
+
+    for (unsigned char c : key) {
+        val = (val << 8) + c;
+        valb += 8;
+        while (valb >= 0) {
+            encoded.push_back(base64_chars[(val >> valb) & 0x3F]);
+            valb -= 6;
+        }
+    }
+    if (valb > -6) {
+        encoded.push_back(base64_chars[((val << 8) >> (valb + 8)) & 0x3F]);
+    }
+    while (encoded.size() % 4) {
+        encoded.push_back('=');
+    }
+
+    return encoded;
+}
+
+auto ConfigServiceImpl::DecryptApiKey(const std::string& encrypted) -> std::string {
+    // Simple base64 decoding
+    static const std::string base64_chars =
+        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+    std::string decoded;
+    int val = 0;
+    int valb = -8;
+
+    for (char c : encrypted) {
+        if (c == '=') break;
+        auto pos = base64_chars.find(c);
+        if (pos == std::string::npos) continue;
+        val = (val << 6) + static_cast<int>(pos);
+        valb += 6;
+        if (valb >= 0) {
+            decoded.push_back(static_cast<char>((val >> valb) & 0xFF));
+            valb -= 8;
+        }
+    }
+
+    return decoded;
+}
+
+auto ConfigServiceImpl::GetApiKey(const std::string& provider_id, const std::string& workspace_id)
+    -> std::string {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    // Check workspace BYOK first
+    std::string key = workspace_id + ":" + provider_id;
+    auto ws_it = workspace_keys_.find(key);
+    if (ws_it != workspace_keys_.end()) {
+        return ws_it->second;
+    }
+
+    // Fall back to provider's API key
+    auto prov_it = providers_.find(provider_id);
+    if (prov_it != providers_.end()) {
+        return prov_it->second.api_key;
+    }
+
+    return "";
+}
+
+::grpc::Status ConfigServiceImpl::GetProviderConfig(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::GetProviderConfigRequest* request,
+    ::smartbotic::llm::ProviderConfig* response) {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    auto it = providers_.find(request->provider_id());
+    if (it == providers_.end()) {
+        return ::grpc::Status(::grpc::StatusCode::NOT_FOUND, "Provider not found");
+    }
+
+    *response = ProviderConfigToProto(it->second);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ConfigServiceImpl::SetProviderConfig(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::SetProviderConfigRequest* request,
+    ::smartbotic::llm::ProviderConfig* response) {
+    spdlog::debug("SetProviderConfig: {}", request->id().empty() ? "new" : request->id());
+
+    provider::ProviderConfig config;
+    config.id = request->id().empty() ? session::GenerateId() : request->id();
+    config.name = request->name();
+    config.type = ProtoToProviderType(request->type());
+    config.base_url = request->base_url();
+    config.enabled = request->enabled();
+
+    // Handle API key
+    if (!request->api_key().empty()) {
+        config.api_key = request->api_key();
+    } else {
+        // Keep existing key if not provided
+        std::lock_guard<std::mutex> lock(mutex_);
+        auto it = providers_.find(config.id);
+        if (it != providers_.end()) {
+            config.api_key = it->second.api_key;
+        }
+    }
+
+    // Save to database
+    if (!SaveProvider(config)) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, "Failed to save provider");
+    }
+
+    // Update cache
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        providers_[config.id] = config;
+    }
+
+    // Update or create provider instance
+    if (config.enabled) {
+        (void)provider_factory_->GetOrCreate(config);
+    } else {
+        provider_factory_->RemoveProvider(config.id);
+    }
+
+    *response = ProviderConfigToProto(config);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ConfigServiceImpl::DeleteProviderConfig(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::DeleteProviderConfigRequest* request,
+    ::smartbotic::llm::DeleteProviderConfigResponse* response) {
+    spdlog::debug("DeleteProviderConfig: {}", request->provider_id());
+
+    if (!DeleteProvider(request->provider_id())) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, "Failed to delete provider");
+    }
+
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        providers_.erase(request->provider_id());
+    }
+
+    provider_factory_->RemoveProvider(request->provider_id());
+
+    response->set_deleted(true);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ConfigServiceImpl::ListProviderConfigs(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::ListProviderConfigsRequest* request,
+    ::smartbotic::llm::ListProviderConfigsResponse* response) {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    for (const auto& [id, config] : providers_) {
+        if (!request->include_disabled() && !config.enabled) {
+            continue;
+        }
+        *response->add_providers() = ProviderConfigToProto(config);
+    }
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ConfigServiceImpl::SetWorkspaceApiKey(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::SetWorkspaceApiKeyRequest* request,
+    ::smartbotic::llm::Empty* response) {
+    spdlog::debug("SetWorkspaceApiKey for workspace: {}", request->workspace_id());
+
+    std::string key = request->workspace_id() + ":" + request->provider_id();
+
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        workspace_keys_[key] = request->api_key();
+    }
+
+    // TODO: Persist to database
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ConfigServiceImpl::GetWorkspaceApiKey(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::GetWorkspaceApiKeyRequest* request,
+    ::smartbotic::llm::WorkspaceApiKeyResponse* response) {
+    std::string key = request->workspace_id() + ":" + request->provider_id();
+
+    std::lock_guard<std::mutex> lock(mutex_);
+    auto it = workspace_keys_.find(key);
+
+    response->set_workspace_id(request->workspace_id());
+    response->set_provider_id(request->provider_id());
+    response->set_has_api_key(it != workspace_keys_.end());
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ConfigServiceImpl::DeleteWorkspaceApiKey(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::DeleteWorkspaceApiKeyRequest* request,
+    ::smartbotic::llm::DeleteWorkspaceApiKeyResponse* response) {
+    spdlog::debug("DeleteWorkspaceApiKey for workspace: {}", request->workspace_id());
+
+    std::string key = request->workspace_id() + ":" + request->provider_id();
+
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        workspace_keys_.erase(key);
+    }
+
+    response->set_deleted(true);
+    return ::grpc::Status::OK;
+}
+
+}  // namespace smartbotic::llm::grpc

+ 493 - 0
llm/src/grpc/llm_service.cpp

@@ -0,0 +1,493 @@
+#include "smartbotic/llm/grpc/llm_service.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/llm/grpc/proto_convert.hpp"
+
+namespace smartbotic::llm::grpc {
+
+LLMServiceImpl::LLMServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory,
+                               std::shared_ptr<session::ISessionStore> session_store,
+                               std::shared_ptr<tool::ToolRegistry> tool_registry,
+                               const std::string& default_model)
+    : provider_factory_(std::move(provider_factory)),
+      session_store_(std::move(session_store)),
+      tool_registry_(std::move(tool_registry)),
+      default_model_(default_model) {}
+
+auto LLMServiceImpl::GetProvider(const std::string& provider_id,
+                                 const std::string& workspace_id)
+    -> std::shared_ptr<provider::IProvider> {
+    // TODO: Implement provider selection with workspace BYOK support
+    // For now, return first available provider
+    auto providers = provider_factory_->GetAll();
+    if (!providers.empty()) {
+        return providers.front();
+    }
+    return nullptr;
+}
+
+auto LLMServiceImpl::BuildChatRequest(const ::smartbotic::llm::ChatRequest& request,
+                                      const session::Session& session)
+    -> provider::ChatRequest {
+    provider::ChatRequest chat_request;
+
+    // Set model
+    chat_request.model = request.model_id().empty() ? session.model_id : request.model_id();
+    if (chat_request.model.empty()) {
+        chat_request.model = default_model_;
+    }
+
+    // Set system prompt
+    if (!session.system_prompt.empty()) {
+        chat_request.system_prompt = session.system_prompt;
+    }
+
+    // Convert messages from session
+    for (const auto& msg : session.messages) {
+        provider::ChatMessage chat_msg;
+
+        switch (msg.role) {
+            case session::MessageRole::kSystem:
+                chat_msg.role = provider::MessageRole::kSystem;
+                break;
+            case session::MessageRole::kUser:
+                chat_msg.role = provider::MessageRole::kUser;
+                break;
+            case session::MessageRole::kAssistant:
+                chat_msg.role = provider::MessageRole::kAssistant;
+                break;
+            case session::MessageRole::kTool:
+                chat_msg.role = provider::MessageRole::kTool;
+                break;
+        }
+
+        for (const auto& part : msg.content) {
+            provider::ContentPart content_part;
+
+            switch (part.type) {
+                case session::ContentPartType::kText:
+                    content_part.type = provider::ContentPart::Type::kText;
+                    content_part.text = part.text;
+                    break;
+                case session::ContentPartType::kImage:
+                    content_part.type = provider::ContentPart::Type::kImage;
+                    content_part.image_url = part.image_url;
+                    content_part.media_type = part.media_type;
+                    break;
+                case session::ContentPartType::kToolUse:
+                    content_part.type = provider::ContentPart::Type::kToolUse;
+                    content_part.tool_id = part.tool_id;
+                    content_part.tool_name = part.tool_name;
+                    content_part.tool_arguments = part.tool_arguments;
+                    break;
+                case session::ContentPartType::kToolResult:
+                    content_part.type = provider::ContentPart::Type::kToolResult;
+                    content_part.tool_id = part.tool_id;
+                    content_part.text = part.text;
+                    content_part.is_error = part.is_error;
+                    break;
+            }
+
+            chat_msg.content.push_back(std::move(content_part));
+        }
+
+        chat_request.messages.push_back(std::move(chat_msg));
+    }
+
+    // Set parameters
+    if (request.temperature() > 0) {
+        chat_request.temperature = request.temperature();
+    }
+    if (request.max_tokens() > 0) {
+        chat_request.max_tokens = request.max_tokens();
+    }
+
+    // Convert tools
+    for (const auto& tool : request.tools()) {
+        provider::ToolDefinition def;
+        def.name = tool.name();
+        def.description = tool.description();
+        def.input_schema = tool.input_schema();
+        chat_request.tools.push_back(std::move(def));
+    }
+
+    // Add tools from registry
+    auto workspace_tools = tool_registry_->GetToolsForWorkspace(session.workspace_id);
+    for (const auto& tool : workspace_tools) {
+        provider::ToolDefinition def;
+        def.name = tool.name;
+        def.description = tool.description;
+        def.input_schema = tool.input_schema;
+        chat_request.tools.push_back(std::move(def));
+    }
+
+    return chat_request;
+}
+
+::grpc::Status LLMServiceImpl::Chat(::grpc::ServerContext* context,
+                                    const ::smartbotic::llm::ChatRequest* request,
+                                    ::smartbotic::llm::ChatResponse* response) {
+    spdlog::debug("Chat request for session: {}", request->session_id());
+
+    // Get or create session
+    auto session_result = session_store_->GetSession(
+        request->session_id(), request->user_id(), true);
+    if (!session_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::NOT_FOUND, session_result.error);
+    }
+
+    auto session = std::move(session_result.value);
+
+    // Add user message to session
+    session::Message user_message;
+    user_message.id = session::GenerateId();
+    user_message.role = session::MessageRole::kUser;
+    user_message.created_at = session::Now();
+
+    for (const auto& part : request->content()) {
+        user_message.content.push_back(ProtoToContentPart(part));
+    }
+
+    if (request->has_page_context()) {
+        user_message.page_context = ProtoToPageContext(request->page_context());
+    }
+
+    // Save user message
+    auto add_result = session_store_->AddMessage(session.id, session.user_id, user_message);
+    if (!add_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, add_result.error);
+    }
+    session = std::move(add_result.value);
+
+    // Get provider
+    auto provider = GetProvider(request->provider_id(), session.workspace_id);
+    if (!provider) {
+        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available");
+    }
+
+    // Build and send chat request
+    auto chat_request = BuildChatRequest(*request, session);
+    auto chat_result = provider->Chat(chat_request);
+
+    if (!chat_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, chat_result.error);
+    }
+
+    // Create assistant message
+    session::Message assistant_message;
+    assistant_message.id = session::GenerateId();
+    assistant_message.role = session::MessageRole::kAssistant;
+    assistant_message.created_at = session::Now();
+
+    // Add text content
+    if (!chat_result.value.content.empty()) {
+        session::ContentPart text_part;
+        text_part.type = session::ContentPartType::kText;
+        text_part.text = chat_result.value.content;
+        assistant_message.content.push_back(std::move(text_part));
+    }
+
+    // Add tool use content
+    for (const auto& tool_call : chat_result.value.tool_calls) {
+        session::ContentPart tool_part;
+        tool_part.type = session::ContentPartType::kToolUse;
+        tool_part.tool_id = tool_call.id;
+        tool_part.tool_name = tool_call.name;
+        tool_part.tool_arguments = tool_call.arguments;
+        assistant_message.content.push_back(std::move(tool_part));
+    }
+
+    // Save assistant message
+    add_result = session_store_->AddMessage(session.id, session.user_id, assistant_message);
+    if (!add_result.success) {
+        spdlog::warn("Failed to save assistant message: {}", add_result.error);
+    }
+
+    // Build response
+    response->set_session_id(session.id);
+    *response->mutable_user_message() = MessageToProto(user_message);
+    *response->mutable_assistant_message() = MessageToProto(assistant_message);
+    *response->mutable_usage() = UsageInfoToProto(chat_result.value.usage);
+    response->set_finish_reason(FinishReasonToProto(chat_result.value.finish_reason));
+
+    for (const auto& tc : chat_result.value.tool_calls) {
+        *response->add_tool_calls() = ToolCallToProto(tc);
+    }
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status LLMServiceImpl::ChatStream(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::ChatRequest* request,
+    ::grpc::ServerWriter<::smartbotic::llm::ChatStreamChunk>* writer) {
+    spdlog::debug("ChatStream request for session: {}", request->session_id());
+
+    // Get session
+    auto session_result = session_store_->GetSession(
+        request->session_id(), request->user_id(), true);
+    if (!session_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::NOT_FOUND, session_result.error);
+    }
+
+    auto session = std::move(session_result.value);
+
+    // Add user message
+    session::Message user_message;
+    user_message.id = session::GenerateId();
+    user_message.role = session::MessageRole::kUser;
+    user_message.created_at = session::Now();
+
+    for (const auto& part : request->content()) {
+        user_message.content.push_back(ProtoToContentPart(part));
+    }
+
+    if (request->has_page_context()) {
+        user_message.page_context = ProtoToPageContext(request->page_context());
+    }
+
+    auto add_result = session_store_->AddMessage(session.id, session.user_id, user_message);
+    if (!add_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, add_result.error);
+    }
+    session = std::move(add_result.value);
+
+    // Get provider
+    auto provider = GetProvider(request->provider_id(), session.workspace_id);
+    if (!provider) {
+        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available");
+    }
+
+    // Build chat request
+    auto chat_request = BuildChatRequest(*request, session);
+
+    // Accumulated response
+    std::string accumulated_content;
+    std::vector<provider::ToolCall> accumulated_tool_calls;
+    provider::UsageInfo final_usage;
+    provider::FinishReason final_reason = provider::FinishReason::kStop;
+
+    // Stream callback
+    auto callback = [&](const provider::StreamChunk& chunk) -> bool {
+        if (context->IsCancelled()) {
+            return false;
+        }
+
+        ::smartbotic::llm::ChatStreamChunk proto_chunk;
+        proto_chunk.set_session_id(session.id);
+
+        if (!chunk.content_delta.empty()) {
+            proto_chunk.set_content_delta(chunk.content_delta);
+            accumulated_content += chunk.content_delta;
+        }
+
+        if (chunk.tool_call.has_value()) {
+            *proto_chunk.mutable_tool_call() = ToolCallToProto(*chunk.tool_call);
+            accumulated_tool_calls.push_back(*chunk.tool_call);
+        }
+
+        if (chunk.usage.has_value()) {
+            final_usage = *chunk.usage;
+        }
+
+        if (chunk.finish_reason.has_value()) {
+            final_reason = *chunk.finish_reason;
+        }
+
+        writer->Write(proto_chunk);
+        return true;
+    };
+
+    // Execute streaming chat
+    auto chat_result = provider->ChatStream(chat_request, callback);
+
+    if (!chat_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, chat_result.error);
+    }
+
+    // Create and save assistant message
+    session::Message assistant_message;
+    assistant_message.id = session::GenerateId();
+    assistant_message.role = session::MessageRole::kAssistant;
+    assistant_message.created_at = session::Now();
+
+    if (!accumulated_content.empty()) {
+        session::ContentPart text_part;
+        text_part.type = session::ContentPartType::kText;
+        text_part.text = accumulated_content;
+        assistant_message.content.push_back(std::move(text_part));
+    }
+
+    for (const auto& tc : accumulated_tool_calls) {
+        session::ContentPart tool_part;
+        tool_part.type = session::ContentPartType::kToolUse;
+        tool_part.tool_id = tc.id;
+        tool_part.tool_name = tc.name;
+        tool_part.tool_arguments = tc.arguments;
+        assistant_message.content.push_back(std::move(tool_part));
+    }
+
+    session_store_->AddMessage(session.id, session.user_id, assistant_message);
+
+    // Send final metadata chunk
+    ::smartbotic::llm::ChatStreamChunk final_chunk;
+    final_chunk.set_session_id(session.id);
+    auto* metadata = final_chunk.mutable_metadata();
+    *metadata->mutable_user_message() = MessageToProto(user_message);
+    *metadata->mutable_assistant_message() = MessageToProto(assistant_message);
+    *metadata->mutable_usage() = UsageInfoToProto(final_usage);
+    metadata->set_finish_reason(FinishReasonToProto(final_reason));
+
+    writer->Write(final_chunk);
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status LLMServiceImpl::SubmitToolResults(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::SubmitToolResultsRequest* request,
+    ::smartbotic::llm::ChatResponse* response) {
+    // Similar to Chat but starts with tool results instead of user message
+    spdlog::debug("SubmitToolResults for session: {}", request->session_id());
+
+    auto session_result = session_store_->GetSession(
+        request->session_id(), request->user_id(), true);
+    if (!session_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::NOT_FOUND, session_result.error);
+    }
+
+    auto session = std::move(session_result.value);
+
+    // Add tool result messages
+    for (const auto& tool_result : request->tool_results()) {
+        session::Message tool_message;
+        tool_message.id = session::GenerateId();
+        tool_message.role = session::MessageRole::kTool;
+        tool_message.created_at = session::Now();
+
+        session::ContentPart result_part;
+        result_part.type = session::ContentPartType::kToolResult;
+        result_part.tool_id = tool_result.tool_call_id();
+        result_part.text = tool_result.content();
+        result_part.is_error = tool_result.is_error();
+        tool_message.content.push_back(std::move(result_part));
+
+        auto add_result = session_store_->AddMessage(session.id, session.user_id, tool_message);
+        if (add_result.success) {
+            session = std::move(add_result.value);
+        }
+    }
+
+    // Get provider and continue conversation
+    auto provider = GetProvider(request->provider_id(), session.workspace_id);
+    if (!provider) {
+        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available");
+    }
+
+    // Build request from session with tool results
+    provider::ChatRequest chat_request;
+    chat_request.model = request->model_id().empty() ? session.model_id : request->model_id();
+    if (chat_request.model.empty()) {
+        chat_request.model = default_model_;
+    }
+
+    if (!session.system_prompt.empty()) {
+        chat_request.system_prompt = session.system_prompt;
+    }
+
+    // Include all messages including tool results
+    for (const auto& msg : session.messages) {
+        provider::ChatMessage chat_msg;
+        chat_msg.role = msg.role == session::MessageRole::kUser ? provider::MessageRole::kUser
+                      : msg.role == session::MessageRole::kAssistant ? provider::MessageRole::kAssistant
+                      : msg.role == session::MessageRole::kTool ? provider::MessageRole::kTool
+                      : provider::MessageRole::kSystem;
+
+        for (const auto& part : msg.content) {
+            provider::ContentPart cp;
+            cp.type = part.type == session::ContentPartType::kText ? provider::ContentPart::Type::kText
+                    : part.type == session::ContentPartType::kImage ? provider::ContentPart::Type::kImage
+                    : part.type == session::ContentPartType::kToolUse ? provider::ContentPart::Type::kToolUse
+                    : provider::ContentPart::Type::kToolResult;
+            cp.text = part.text;
+            cp.tool_id = part.tool_id;
+            cp.tool_name = part.tool_name;
+            cp.tool_arguments = part.tool_arguments;
+            cp.image_url = part.image_url;
+            cp.media_type = part.media_type;
+            cp.is_error = part.is_error;
+            chat_msg.content.push_back(std::move(cp));
+        }
+
+        chat_request.messages.push_back(std::move(chat_msg));
+    }
+
+    auto chat_result = provider->Chat(chat_request);
+    if (!chat_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, chat_result.error);
+    }
+
+    // Create assistant message
+    session::Message assistant_message;
+    assistant_message.id = session::GenerateId();
+    assistant_message.role = session::MessageRole::kAssistant;
+    assistant_message.created_at = session::Now();
+
+    if (!chat_result.value.content.empty()) {
+        session::ContentPart text_part;
+        text_part.type = session::ContentPartType::kText;
+        text_part.text = chat_result.value.content;
+        assistant_message.content.push_back(std::move(text_part));
+    }
+
+    for (const auto& tc : chat_result.value.tool_calls) {
+        session::ContentPart tool_part;
+        tool_part.type = session::ContentPartType::kToolUse;
+        tool_part.tool_id = tc.id;
+        tool_part.tool_name = tc.name;
+        tool_part.tool_arguments = tc.arguments;
+        assistant_message.content.push_back(std::move(tool_part));
+    }
+
+    session_store_->AddMessage(session.id, session.user_id, assistant_message);
+
+    // Build response
+    response->set_session_id(session.id);
+    *response->mutable_assistant_message() = MessageToProto(assistant_message);
+    *response->mutable_usage() = UsageInfoToProto(chat_result.value.usage);
+    response->set_finish_reason(FinishReasonToProto(chat_result.value.finish_reason));
+
+    for (const auto& tc : chat_result.value.tool_calls) {
+        *response->add_tool_calls() = ToolCallToProto(tc);
+    }
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status LLMServiceImpl::SubmitToolResultsStream(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::SubmitToolResultsRequest* request,
+    ::grpc::ServerWriter<::smartbotic::llm::ChatStreamChunk>* writer) {
+    // Similar to ChatStream but with tool results
+    // For brevity, delegating to non-streaming version and wrapping result
+    ::smartbotic::llm::ChatResponse response;
+    auto status = SubmitToolResults(context, request, &response);
+
+    if (!status.ok()) {
+        return status;
+    }
+
+    // Send as single chunk with metadata
+    ::smartbotic::llm::ChatStreamChunk chunk;
+    chunk.set_session_id(response.session_id());
+    auto* metadata = chunk.mutable_metadata();
+    *metadata->mutable_assistant_message() = response.assistant_message();
+    *metadata->mutable_usage() = response.usage();
+    metadata->set_finish_reason(response.finish_reason());
+
+    writer->Write(chunk);
+    return ::grpc::Status::OK;
+}
+
+}  // namespace smartbotic::llm::grpc

+ 151 - 0
llm/src/grpc/model_service.cpp

@@ -0,0 +1,151 @@
+#include "smartbotic/llm/grpc/model_service.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/llm/grpc/proto_convert.hpp"
+
+namespace smartbotic::llm::grpc {
+
+ModelServiceImpl::ModelServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory)
+    : provider_factory_(std::move(provider_factory)) {}
+
+::grpc::Status ModelServiceImpl::ListProviders(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::ListProvidersRequest* request,
+    ::smartbotic::llm::ListProvidersResponse* response) {
+    spdlog::debug("ListProviders");
+
+    auto providers = provider_factory_->GetAll();
+
+    for (const auto& provider : providers) {
+        auto& config = provider->GetConfig();
+
+        if (!request->include_disabled() && !config.enabled) {
+            continue;
+        }
+
+        auto* info = response->add_providers();
+        info->set_id(config.id);
+        info->set_name(config.name);
+        info->set_type(ProviderTypeToProto(config.type));
+        info->set_base_url(config.base_url);
+        info->set_enabled(config.enabled);
+
+        // Check health
+        auto health_result = provider->HealthCheck();
+        info->set_healthy(health_result.success && health_result.value);
+    }
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ModelServiceImpl::ListModels(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::ListModelsRequest* request,
+    ::smartbotic::llm::ListModelsResponse* response) {
+    spdlog::debug("ListModels");
+
+    auto providers = provider_factory_->GetAll();
+
+    for (const auto& provider : providers) {
+        auto& config = provider->GetConfig();
+
+        // Filter by provider_id if specified
+        if (!request->provider_id().empty() && config.id != request->provider_id()) {
+            continue;
+        }
+
+        // Filter by provider_type if specified
+        if (request->provider_type() != ::smartbotic::llm::PROVIDER_TYPE_UNSPECIFIED) {
+            if (ProviderTypeToProto(config.type) != request->provider_type()) {
+                continue;
+            }
+        }
+
+        // Skip disabled providers
+        if (!config.enabled) {
+            continue;
+        }
+
+        // Get models from provider
+        auto models_result = provider->ListModels();
+        if (!models_result.success) {
+            spdlog::warn("Failed to list models from {}: {}", config.name, models_result.error);
+            continue;
+        }
+
+        for (const auto& model : models_result.value) {
+            auto* model_info = response->add_models();
+            model_info->set_id(model.id);
+            model_info->set_name(model.name);
+            model_info->set_provider_id(config.id);
+            model_info->set_provider_type(ProviderTypeToProto(config.type));
+            model_info->set_context_length(model.context_length);
+            model_info->set_supports_tools(model.supports_tools);
+            model_info->set_supports_vision(model.supports_vision);
+        }
+    }
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ModelServiceImpl::RefreshModels(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::RefreshModelsRequest* request,
+    ::smartbotic::llm::RefreshModelsResponse* response) {
+    spdlog::debug("RefreshModels");
+
+    auto providers = provider_factory_->GetAll();
+    int models_found = 0;
+
+    for (const auto& provider : providers) {
+        auto& config = provider->GetConfig();
+
+        // Filter by provider_id if specified
+        if (!request->provider_id().empty() && config.id != request->provider_id()) {
+            continue;
+        }
+
+        auto models_result = provider->ListModels();
+        if (!models_result.success) {
+            response->add_errors("Provider " + config.name + ": " + models_result.error);
+            continue;
+        }
+
+        models_found += static_cast<int>(models_result.value.size());
+    }
+
+    response->set_models_found(models_found);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status ModelServiceImpl::GetProviderHealth(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::GetProviderHealthRequest* request,
+    ::smartbotic::llm::GetProviderHealthResponse* response) {
+    spdlog::debug("GetProviderHealth: {}", request->provider_id());
+
+    auto provider = provider_factory_->Get(request->provider_id());
+    if (!provider) {
+        return ::grpc::Status(::grpc::StatusCode::NOT_FOUND, "Provider not found");
+    }
+
+    response->set_provider_id(request->provider_id());
+
+    auto start = std::chrono::steady_clock::now();
+    auto health_result = provider->HealthCheck();
+    auto end = std::chrono::steady_clock::now();
+
+    auto latency_ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
+
+    response->set_healthy(health_result.success && health_result.value);
+    if (!health_result.success) {
+        response->set_error(health_result.error);
+    }
+    response->set_latency_ms(latency_ms);
+    *response->mutable_checked_at() = TimePointToProtoTimestamp(std::chrono::system_clock::now());
+
+    return ::grpc::Status::OK;
+}
+
+}  // namespace smartbotic::llm::grpc

+ 289 - 0
llm/src/grpc/proto_convert.cpp

@@ -0,0 +1,289 @@
+#include "smartbotic/llm/grpc/proto_convert.hpp"
+
+namespace smartbotic::llm::grpc {
+
+auto TimePointToProtoTimestamp(std::chrono::system_clock::time_point tp)
+    -> ::smartbotic::llm::Timestamp {
+    ::smartbotic::llm::Timestamp ts;
+    auto duration = tp.time_since_epoch();
+    auto seconds = std::chrono::duration_cast<std::chrono::seconds>(duration);
+    auto nanos = std::chrono::duration_cast<std::chrono::nanoseconds>(duration - seconds);
+    ts.set_seconds(seconds.count());
+    ts.set_nanos(static_cast<int32_t>(nanos.count()));
+    return ts;
+}
+
+auto ProtoTimestampToTimePoint(const ::smartbotic::llm::Timestamp& ts)
+    -> std::chrono::system_clock::time_point {
+    auto duration = std::chrono::seconds(ts.seconds()) +
+                    std::chrono::nanoseconds(ts.nanos());
+    return std::chrono::system_clock::time_point(
+        std::chrono::duration_cast<std::chrono::system_clock::duration>(duration));
+}
+
+auto MessageRoleToProto(session::MessageRole role) -> ::smartbotic::llm::MessageRole {
+    switch (role) {
+        case session::MessageRole::kSystem:
+            return ::smartbotic::llm::MESSAGE_ROLE_SYSTEM;
+        case session::MessageRole::kUser:
+            return ::smartbotic::llm::MESSAGE_ROLE_USER;
+        case session::MessageRole::kAssistant:
+            return ::smartbotic::llm::MESSAGE_ROLE_ASSISTANT;
+        case session::MessageRole::kTool:
+            return ::smartbotic::llm::MESSAGE_ROLE_TOOL;
+    }
+    return ::smartbotic::llm::MESSAGE_ROLE_USER;
+}
+
+auto ProtoToMessageRole(::smartbotic::llm::MessageRole role) -> session::MessageRole {
+    switch (role) {
+        case ::smartbotic::llm::MESSAGE_ROLE_SYSTEM:
+            return session::MessageRole::kSystem;
+        case ::smartbotic::llm::MESSAGE_ROLE_USER:
+            return session::MessageRole::kUser;
+        case ::smartbotic::llm::MESSAGE_ROLE_ASSISTANT:
+            return session::MessageRole::kAssistant;
+        case ::smartbotic::llm::MESSAGE_ROLE_TOOL:
+            return session::MessageRole::kTool;
+        default:
+            return session::MessageRole::kUser;
+    }
+}
+
+auto ProviderTypeToProto(provider::ProviderType type) -> ::smartbotic::llm::ProviderType {
+    switch (type) {
+        case provider::ProviderType::kOpenAI:
+            return ::smartbotic::llm::PROVIDER_TYPE_OPENAI;
+        case provider::ProviderType::kAnthropic:
+            return ::smartbotic::llm::PROVIDER_TYPE_ANTHROPIC;
+    }
+    return ::smartbotic::llm::PROVIDER_TYPE_OPENAI;
+}
+
+auto ProtoToProviderType(::smartbotic::llm::ProviderType type) -> provider::ProviderType {
+    switch (type) {
+        case ::smartbotic::llm::PROVIDER_TYPE_OPENAI:
+            return provider::ProviderType::kOpenAI;
+        case ::smartbotic::llm::PROVIDER_TYPE_ANTHROPIC:
+            return provider::ProviderType::kAnthropic;
+        default:
+            return provider::ProviderType::kOpenAI;
+    }
+}
+
+auto FinishReasonToProto(provider::FinishReason reason) -> ::smartbotic::llm::FinishReason {
+    switch (reason) {
+        case provider::FinishReason::kStop:
+            return ::smartbotic::llm::FINISH_REASON_STOP;
+        case provider::FinishReason::kLength:
+            return ::smartbotic::llm::FINISH_REASON_LENGTH;
+        case provider::FinishReason::kToolUse:
+            return ::smartbotic::llm::FINISH_REASON_TOOL_USE;
+        case provider::FinishReason::kContentFilter:
+            return ::smartbotic::llm::FINISH_REASON_CONTENT_FILTER;
+        case provider::FinishReason::kError:
+            return ::smartbotic::llm::FINISH_REASON_ERROR;
+    }
+    return ::smartbotic::llm::FINISH_REASON_STOP;
+}
+
+auto UsageInfoToProto(const provider::UsageInfo& usage) -> ::smartbotic::llm::UsageInfo {
+    ::smartbotic::llm::UsageInfo proto;
+    proto.set_prompt_tokens(usage.prompt_tokens);
+    proto.set_completion_tokens(usage.completion_tokens);
+    proto.set_total_tokens(usage.total_tokens);
+    return proto;
+}
+
+auto ToolCallToProto(const provider::ToolCall& call) -> ::smartbotic::llm::ToolCall {
+    ::smartbotic::llm::ToolCall proto;
+    proto.set_id(call.id);
+    proto.set_name(call.name);
+    proto.set_arguments(call.arguments);
+    return proto;
+}
+
+auto ContentPartToProto(const session::ContentPart& part) -> ::smartbotic::llm::ContentPart {
+    ::smartbotic::llm::ContentPart proto;
+
+    switch (part.type) {
+        case session::ContentPartType::kText:
+            proto.set_text(part.text);
+            break;
+        case session::ContentPartType::kImage: {
+            auto* image = proto.mutable_image();
+            image->set_url(part.image_url);
+            image->set_media_type(part.media_type);
+            break;
+        }
+        case session::ContentPartType::kToolUse: {
+            auto* tool_use = proto.mutable_tool_use();
+            tool_use->set_id(part.tool_id);
+            tool_use->set_name(part.tool_name);
+            tool_use->set_arguments(part.tool_arguments);
+            break;
+        }
+        case session::ContentPartType::kToolResult: {
+            auto* tool_result = proto.mutable_tool_result();
+            tool_result->set_tool_use_id(part.tool_id);
+            tool_result->set_content(part.text);
+            tool_result->set_is_error(part.is_error);
+            break;
+        }
+    }
+
+    return proto;
+}
+
+auto ProtoToContentPart(const ::smartbotic::llm::ContentPart& proto) -> session::ContentPart {
+    session::ContentPart part;
+
+    if (proto.has_text()) {
+        part.type = session::ContentPartType::kText;
+        part.text = proto.text();
+    } else if (proto.has_image()) {
+        part.type = session::ContentPartType::kImage;
+        part.image_url = proto.image().url();
+        part.media_type = proto.image().media_type();
+    } else if (proto.has_tool_use()) {
+        part.type = session::ContentPartType::kToolUse;
+        part.tool_id = proto.tool_use().id();
+        part.tool_name = proto.tool_use().name();
+        part.tool_arguments = proto.tool_use().arguments();
+    } else if (proto.has_tool_result()) {
+        part.type = session::ContentPartType::kToolResult;
+        part.tool_id = proto.tool_result().tool_use_id();
+        part.text = proto.tool_result().content();
+        part.is_error = proto.tool_result().is_error();
+    }
+
+    return part;
+}
+
+auto PageContextToProto(const session::PageContext& context) -> ::smartbotic::llm::PageContext {
+    ::smartbotic::llm::PageContext proto;
+    proto.set_path(context.path);
+    proto.set_title(context.title);
+    proto.set_workspace_id(context.workspace_id);
+    proto.set_collection(context.collection);
+    proto.set_view_id(context.view_id);
+    return proto;
+}
+
+auto ProtoToPageContext(const ::smartbotic::llm::PageContext& proto) -> session::PageContext {
+    session::PageContext context;
+    context.path = proto.path();
+    context.title = proto.title();
+    context.workspace_id = proto.workspace_id();
+    context.collection = proto.collection();
+    context.view_id = proto.view_id();
+    return context;
+}
+
+auto MessageToProto(const session::Message& message) -> ::smartbotic::llm::Message {
+    ::smartbotic::llm::Message proto;
+    proto.set_id(message.id);
+    proto.set_role(MessageRoleToProto(message.role));
+    *proto.mutable_created_at() = TimePointToProtoTimestamp(message.created_at);
+
+    for (const auto& part : message.content) {
+        *proto.add_content() = ContentPartToProto(part);
+    }
+
+    if (message.page_context.has_value()) {
+        *proto.mutable_page_context() = PageContextToProto(*message.page_context);
+    }
+
+    return proto;
+}
+
+auto ProtoToMessage(const ::smartbotic::llm::Message& proto) -> session::Message {
+    session::Message message;
+    message.id = proto.id();
+    message.role = ProtoToMessageRole(proto.role());
+    message.created_at = ProtoTimestampToTimePoint(proto.created_at());
+
+    for (const auto& part_proto : proto.content()) {
+        message.content.push_back(ProtoToContentPart(part_proto));
+    }
+
+    if (proto.has_page_context()) {
+        message.page_context = ProtoToPageContext(proto.page_context());
+    }
+
+    return message;
+}
+
+auto SessionToProto(const session::Session& session) -> ::smartbotic::llm::Session {
+    ::smartbotic::llm::Session proto;
+    proto.set_id(session.id);
+    proto.set_user_id(session.user_id);
+    proto.set_workspace_id(session.workspace_id);
+    proto.set_title(session.title);
+    proto.set_model_id(session.model_id);
+    proto.set_system_prompt(session.system_prompt);
+    *proto.mutable_created_at() = TimePointToProtoTimestamp(session.created_at);
+    *proto.mutable_updated_at() = TimePointToProtoTimestamp(session.updated_at);
+
+    for (const auto& msg : session.messages) {
+        *proto.add_messages() = MessageToProto(msg);
+    }
+
+    return proto;
+}
+
+auto ProtoToSession(const ::smartbotic::llm::Session& proto) -> session::Session {
+    session::Session session;
+    session.id = proto.id();
+    session.user_id = proto.user_id();
+    session.workspace_id = proto.workspace_id();
+    session.title = proto.title();
+    session.model_id = proto.model_id();
+    session.system_prompt = proto.system_prompt();
+    session.created_at = ProtoTimestampToTimePoint(proto.created_at());
+    session.updated_at = ProtoTimestampToTimePoint(proto.updated_at());
+
+    for (const auto& msg_proto : proto.messages()) {
+        session.messages.push_back(ProtoToMessage(msg_proto));
+    }
+
+    return session;
+}
+
+auto SessionSummaryToProto(const session::SessionSummary& summary)
+    -> ::smartbotic::llm::SessionSummary {
+    ::smartbotic::llm::SessionSummary proto;
+    proto.set_id(summary.id);
+    proto.set_user_id(summary.user_id);
+    proto.set_workspace_id(summary.workspace_id);
+    proto.set_title(summary.title);
+    proto.set_model_id(summary.model_id);
+    proto.set_message_count(summary.message_count);
+    *proto.mutable_created_at() = TimePointToProtoTimestamp(summary.created_at);
+    *proto.mutable_updated_at() = TimePointToProtoTimestamp(summary.updated_at);
+    return proto;
+}
+
+auto ModelInfoToProto(const provider::ModelInfo& info) -> ::smartbotic::llm::ModelInfo {
+    ::smartbotic::llm::ModelInfo proto;
+    proto.set_id(info.id);
+    proto.set_name(info.name);
+    proto.set_context_length(info.context_length);
+    proto.set_supports_tools(info.supports_tools);
+    proto.set_supports_vision(info.supports_vision);
+    return proto;
+}
+
+auto ProviderConfigToProto(const provider::ProviderConfig& config)
+    -> ::smartbotic::llm::ProviderConfig {
+    ::smartbotic::llm::ProviderConfig proto;
+    proto.set_id(config.id);
+    proto.set_name(config.name);
+    proto.set_type(ProviderTypeToProto(config.type));
+    proto.set_base_url(config.base_url);
+    proto.set_enabled(config.enabled);
+    proto.set_has_api_key(!config.api_key.empty());
+    return proto;
+}
+
+}  // namespace smartbotic::llm::grpc

+ 116 - 0
llm/src/grpc/server.cpp

@@ -0,0 +1,116 @@
+#include "smartbotic/llm/grpc/server.hpp"
+
+#include <grpcpp/ext/proto_server_reflection_plugin.h>
+#include <grpcpp/health_check_service_interface.h>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::llm::grpc {
+
+GrpcServer::GrpcServer(ServerConfig config) : config_(std::move(config)) {}
+
+GrpcServer::~GrpcServer() {
+    if (running_) {
+        Stop();
+    }
+}
+
+auto GrpcServer::Start() -> bool {
+    spdlog::info("Starting LLM gRPC server...");
+
+    // Create provider factory
+    provider_factory_ = std::make_shared<provider::ProviderFactory>();
+
+    // Create session store
+    session::DatabaseSessionStoreConfig store_config;
+    store_config.database_address = config_.database_address;
+    store_config.default_model = config_.default_model;
+
+    auto db_store = std::make_shared<session::DatabaseSessionStore>(store_config);
+    if (!db_store->Initialize()) {
+        spdlog::error("Failed to initialize session store");
+        return false;
+    }
+    session_store_ = db_store;
+
+    // Create tool registry
+    tool_registry_ = std::make_shared<tool::ToolRegistry>();
+
+    // Create gRPC services
+    llm_service_ = std::make_unique<LLMServiceImpl>(
+        provider_factory_, session_store_, tool_registry_, config_.default_model);
+
+    session_service_ = std::make_unique<SessionServiceImpl>(
+        session_store_, config_.default_model);
+
+    model_service_ = std::make_unique<ModelServiceImpl>(provider_factory_);
+
+    config_service_ = std::make_unique<ConfigServiceImpl>(
+        provider_factory_, config_.database_address);
+    if (!config_service_->Initialize()) {
+        spdlog::warn("Failed to initialize config service - providers may not be loaded");
+    }
+
+    // Enable gRPC reflection and health check
+    ::grpc::EnableDefaultHealthCheckService(true);
+    ::grpc::reflection::InitProtoReflectionServerBuilderPlugin();
+
+    // Build server
+    ::grpc::ServerBuilder builder;
+    builder.AddListeningPort(config_.GetListenAddress(),
+                             ::grpc::InsecureServerCredentials());
+
+    // Register services
+    builder.RegisterService(llm_service_.get());
+    builder.RegisterService(session_service_.get());
+    builder.RegisterService(model_service_.get());
+    builder.RegisterService(config_service_.get());
+
+    // Start server
+    server_ = builder.BuildAndStart();
+    if (!server_) {
+        spdlog::error("Failed to start gRPC server");
+        return false;
+    }
+
+    running_ = true;
+    spdlog::info("LLM gRPC server listening on {}", config_.GetListenAddress());
+    return true;
+}
+
+void GrpcServer::RequestShutdown() {
+    if (server_) {
+        spdlog::info("Requesting LLM server shutdown...");
+        server_->Shutdown();
+    }
+}
+
+void GrpcServer::Wait() {
+    if (server_) {
+        server_->Wait();
+    }
+}
+
+void GrpcServer::Cleanup() {
+    spdlog::info("Cleaning up LLM server resources...");
+
+    // Clear services
+    llm_service_.reset();
+    session_service_.reset();
+    model_service_.reset();
+    config_service_.reset();
+
+    // Clear core components
+    provider_factory_.reset();
+    session_store_.reset();
+    tool_registry_.reset();
+
+    running_ = false;
+}
+
+void GrpcServer::Stop() {
+    RequestShutdown();
+    Wait();
+    Cleanup();
+}
+
+}  // namespace smartbotic::llm::grpc

+ 158 - 0
llm/src/grpc/session_service.cpp

@@ -0,0 +1,158 @@
+#include "smartbotic/llm/grpc/session_service.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/llm/grpc/proto_convert.hpp"
+
+namespace smartbotic::llm::grpc {
+
+SessionServiceImpl::SessionServiceImpl(std::shared_ptr<session::ISessionStore> session_store,
+                                       const std::string& default_model)
+    : session_store_(std::move(session_store)), default_model_(default_model) {}
+
+::grpc::Status SessionServiceImpl::CreateSession(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::CreateSessionRequest* request,
+    ::smartbotic::llm::Session* response) {
+    spdlog::debug("CreateSession for user: {}", request->user_id());
+
+    session::CreateSessionRequest create_request;
+    create_request.user_id = request->user_id();
+    create_request.workspace_id = request->workspace_id();
+    create_request.title = request->title();
+    create_request.model_id = request->model_id().empty() ? default_model_ : request->model_id();
+    create_request.system_prompt = request->system_prompt();
+
+    auto result = session_store_->CreateSession(create_request);
+    if (!result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, result.error);
+    }
+
+    *response = SessionToProto(result.value);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status SessionServiceImpl::GetSession(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::GetSessionRequest* request,
+    ::smartbotic::llm::Session* response) {
+    spdlog::debug("GetSession: {}", request->session_id());
+
+    auto result = session_store_->GetSession(
+        request->session_id(), request->user_id(), request->include_messages());
+
+    if (!result.success) {
+        if (result.error == "Session not found" || result.error == "Access denied") {
+            return ::grpc::Status(::grpc::StatusCode::NOT_FOUND, result.error);
+        }
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, result.error);
+    }
+
+    *response = SessionToProto(result.value);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status SessionServiceImpl::ListSessions(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::ListSessionsRequest* request,
+    ::smartbotic::llm::ListSessionsResponse* response) {
+    spdlog::debug("ListSessions for user: {}", request->user_id());
+
+    session::ListSessionsRequest list_request;
+    list_request.user_id = request->user_id();
+    list_request.workspace_id = request->workspace_id();
+    list_request.page_size = request->page_size() > 0 ? request->page_size() : 50;
+    list_request.page_token = request->page_token();
+
+    auto result = session_store_->ListSessions(list_request);
+    if (!result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, result.error);
+    }
+
+    for (const auto& summary : result.value.sessions) {
+        *response->add_sessions() = SessionSummaryToProto(summary);
+    }
+
+    response->set_next_page_token(result.value.next_page_token);
+    response->set_total_count(result.value.total_count);
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status SessionServiceImpl::UpdateSession(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::UpdateSessionRequest* request,
+    ::smartbotic::llm::Session* response) {
+    spdlog::debug("UpdateSession: {}", request->session_id());
+
+    session::UpdateSessionRequest update_request;
+    update_request.session_id = request->session_id();
+    update_request.user_id = request->user_id();
+
+    if (!request->title().empty()) {
+        update_request.title = request->title();
+    }
+    if (!request->model_id().empty()) {
+        update_request.model_id = request->model_id();
+    }
+    if (!request->system_prompt().empty()) {
+        update_request.system_prompt = request->system_prompt();
+    }
+
+    auto result = session_store_->UpdateSession(update_request);
+    if (!result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, result.error);
+    }
+
+    *response = SessionToProto(result.value);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status SessionServiceImpl::DeleteSession(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::DeleteSessionRequest* request,
+    ::smartbotic::llm::DeleteSessionResponse* response) {
+    spdlog::debug("DeleteSession: {}", request->session_id());
+
+    auto result = session_store_->DeleteSession(request->session_id(), request->user_id());
+    if (!result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, result.error);
+    }
+
+    response->set_deleted(result.value);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status SessionServiceImpl::ClearSessionMessages(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::ClearSessionMessagesRequest* request,
+    ::smartbotic::llm::Session* response) {
+    spdlog::debug("ClearSessionMessages: {}", request->session_id());
+
+    auto result = session_store_->ClearMessages(request->session_id(), request->user_id());
+    if (!result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, result.error);
+    }
+
+    *response = SessionToProto(result.value);
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status SessionServiceImpl::AddMessage(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::llm::AddMessageRequest* request,
+    ::smartbotic::llm::Session* response) {
+    spdlog::debug("AddMessage to session: {}", request->session_id());
+
+    auto message = ProtoToMessage(request->message());
+    auto result = session_store_->AddMessage(request->session_id(), request->user_id(), message);
+
+    if (!result.success) {
+        return ::grpc::Status(::grpc::StatusCode::INTERNAL, result.error);
+    }
+
+    *response = SessionToProto(result.value);
+    return ::grpc::Status::OK;
+}
+
+}  // namespace smartbotic::llm::grpc

+ 168 - 0
llm/src/main.cpp

@@ -0,0 +1,168 @@
+#include <spdlog/spdlog.h>
+
+#include <atomic>
+#include <csignal>
+#include <cstdlib>
+#include <string>
+#include <thread>
+#include <unistd.h>
+
+#include "smartbotic/common.hpp"
+#include "smartbotic/llm/config.hpp"
+#include "smartbotic/llm/grpc/server.hpp"
+#include "smartbotic/llm/server_config.hpp"
+
+namespace {
+
+// Global server pointer for signal handling
+smartbotic::llm::grpc::GrpcServer* g_server = nullptr;
+
+// Atomic flag for shutdown tracking
+std::atomic<bool> g_shutdown_requested{false};
+
+// Self-pipe for async-signal-safe notification
+int g_shutdown_pipe[2] = {-1, -1};
+
+void SignalHandler(int signal) {
+    if (g_shutdown_requested.exchange(true)) {
+        // Second signal - force exit
+        const char* msg = "Received second signal, forcing immediate exit\n";
+        [[maybe_unused]] auto n = write(STDERR_FILENO, msg, 48);
+        std::_Exit(1);
+    }
+
+    // Write to pipe to notify shutdown thread (async-signal-safe)
+    char sig = static_cast<char>(signal);
+    [[maybe_unused]] auto n = write(g_shutdown_pipe[1], &sig, 1);
+}
+
+// Shutdown thread that waits for signal and calls gRPC Shutdown()
+void ShutdownThread() {
+    char sig = 0;
+    // Block until signal is received (read from pipe)
+    ssize_t n = read(g_shutdown_pipe[0], &sig, 1);
+    if (n > 0) {
+        const char* signal_name = (sig == SIGINT) ? "SIGINT" : "SIGTERM";
+        spdlog::info("Received {} ({}), initiating graceful shutdown...", signal_name,
+                     static_cast<int>(sig));
+
+        // Safe to call gRPC Shutdown() from this thread (not from signal handler)
+        if (g_server != nullptr) {
+            g_server->RequestShutdown();
+        }
+    }
+}
+
+auto SetLogLevel(smartbotic::llm::LogLevel level) {
+    switch (level) {
+        case smartbotic::llm::LogLevel::kTrace:
+            spdlog::set_level(spdlog::level::trace);
+            break;
+        case smartbotic::llm::LogLevel::kDebug:
+            spdlog::set_level(spdlog::level::debug);
+            break;
+        case smartbotic::llm::LogLevel::kInfo:
+            spdlog::set_level(spdlog::level::info);
+            break;
+        case smartbotic::llm::LogLevel::kWarn:
+            spdlog::set_level(spdlog::level::warn);
+            break;
+        case smartbotic::llm::LogLevel::kError:
+            spdlog::set_level(spdlog::level::err);
+            break;
+        case smartbotic::llm::LogLevel::kCritical:
+            spdlog::set_level(spdlog::level::critical);
+            break;
+        case smartbotic::llm::LogLevel::kOff:
+            spdlog::set_level(spdlog::level::off);
+            break;
+    }
+}
+
+}  // namespace
+
+int main(int argc, char* argv[]) {
+    // Initialize common logging first (with default settings)
+    smartbotic::common::Initialize("smartbotic-llm");
+
+    spdlog::info("SmartBotic LLM Service starting...");
+
+    // Load configuration from file, environment, and command line
+    auto config = smartbotic::llm::ConfigLoader::Load(argc, argv);
+
+    // Apply configured log level
+    SetLogLevel(config.log_level);
+
+    // Print configuration
+    smartbotic::llm::ConfigLoader::PrintConfig(config);
+
+    // Create self-pipe for async-signal-safe shutdown notification
+    if (pipe(g_shutdown_pipe) == -1) {
+        spdlog::error("Failed to create shutdown pipe");
+        smartbotic::common::Shutdown();
+        return 1;
+    }
+
+    // Convert LlmConfig to ServerConfig for the gRPC server
+    auto server_config = smartbotic::llm::ServerConfig::FromConfig(config);
+
+    // Create and start the server
+    smartbotic::llm::grpc::GrpcServer server(server_config);
+    g_server = &server;
+
+    // Start shutdown thread that will call gRPC Shutdown() when signaled
+    std::thread shutdown_thread(ShutdownThread);
+
+    // Set up signal handlers for graceful shutdown (SIGTERM/SIGINT)
+    struct sigaction sa{};
+    sa.sa_handler = SignalHandler;
+    sigemptyset(&sa.sa_mask);
+    sa.sa_flags = 0;
+
+    if (sigaction(SIGINT, &sa, nullptr) == -1) {
+        spdlog::error("Failed to set SIGINT handler");
+        close(g_shutdown_pipe[1]);
+        shutdown_thread.join();
+        close(g_shutdown_pipe[0]);
+        smartbotic::common::Shutdown();
+        return 1;
+    }
+    if (sigaction(SIGTERM, &sa, nullptr) == -1) {
+        spdlog::error("Failed to set SIGTERM handler");
+        close(g_shutdown_pipe[1]);
+        shutdown_thread.join();
+        close(g_shutdown_pipe[0]);
+        smartbotic::common::Shutdown();
+        return 1;
+    }
+
+    spdlog::info("Signal handlers registered for SIGINT and SIGTERM");
+
+    if (!server.Start()) {
+        spdlog::error("Failed to start server");
+        close(g_shutdown_pipe[1]);
+        shutdown_thread.join();
+        close(g_shutdown_pipe[0]);
+        smartbotic::common::Shutdown();
+        return 1;
+    }
+
+    // Wait for server to stop (either by signal or error)
+    server.Wait();
+
+    // Wait for shutdown thread to complete
+    shutdown_thread.join();
+
+    // Perform cleanup after Wait() returns (safe to do in main thread)
+    server.Cleanup();
+
+    // Close pipe
+    close(g_shutdown_pipe[0]);
+    close(g_shutdown_pipe[1]);
+
+    spdlog::info("SmartBotic LLM Service shutdown complete");
+    g_server = nullptr;
+    smartbotic::common::Shutdown();
+
+    return 0;
+}

+ 607 - 0
llm/src/provider/anthropic_provider.cpp

@@ -0,0 +1,607 @@
+#include "smartbotic/llm/provider/anthropic_provider.hpp"
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include <sstream>
+
+namespace smartbotic::llm::provider {
+
+namespace {
+
+constexpr const char* kDefaultBaseUrl = "https://api.anthropic.com/v1";
+
+auto ExtractHostAndPath(const std::string& url) -> std::pair<std::string, std::string> {
+    std::string host;
+    std::string path_prefix;
+
+    // Remove protocol
+    std::string work = url;
+    if (work.starts_with("https://")) {
+        work = work.substr(8);
+    } else if (work.starts_with("http://")) {
+        work = work.substr(7);
+    }
+
+    // Find first slash for path
+    auto slash_pos = work.find('/');
+    if (slash_pos != std::string::npos) {
+        host = work.substr(0, slash_pos);
+        path_prefix = work.substr(slash_pos);
+        // Remove trailing slash
+        if (!path_prefix.empty() && path_prefix.back() == '/') {
+            path_prefix.pop_back();
+        }
+    } else {
+        host = work;
+    }
+
+    return {host, path_prefix};
+}
+
+auto RoleToString(MessageRole role) -> std::string {
+    switch (role) {
+        case MessageRole::kSystem:
+            return "system";  // Anthropic handles system differently
+        case MessageRole::kUser:
+            return "user";
+        case MessageRole::kAssistant:
+            return "assistant";
+        case MessageRole::kTool:
+            return "user";  // Tool results go as user messages in Anthropic
+    }
+    return "user";
+}
+
+}  // namespace
+
+AnthropicProvider::AnthropicProvider(const ProviderConfig& config) : config_(config) {
+    if (config_.base_url.empty()) {
+        config_.base_url = kDefaultBaseUrl;
+    }
+}
+
+void AnthropicProvider::UpdateConfig(const ProviderConfig& config) {
+    std::lock_guard<std::mutex> lock(mutex_);
+    config_ = config;
+    if (config_.base_url.empty()) {
+        config_.base_url = kDefaultBaseUrl;
+    }
+}
+
+auto AnthropicProvider::CreateClient() -> std::unique_ptr<httplib::Client> {
+    auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+    // Determine if HTTPS
+    bool use_https = config_.base_url.starts_with("https://");
+    std::string full_host = (use_https ? "https://" : "http://") + host;
+
+    auto client = std::make_unique<httplib::Client>(full_host);
+    client->set_connection_timeout(30);
+    client->set_read_timeout(120);  // LLM responses can be slow
+    client->set_write_timeout(30);
+
+    return client;
+}
+
+auto AnthropicProvider::GetHeaders() -> httplib::Headers {
+    httplib::Headers headers;
+    headers.emplace("Content-Type", "application/json");
+    headers.emplace("anthropic-version", kAnthropicVersion);
+    if (!config_.api_key.empty()) {
+        headers.emplace("x-api-key", config_.api_key);
+    }
+    return headers;
+}
+
+auto AnthropicProvider::HealthCheck() -> Result<bool> {
+    try {
+        auto client = CreateClient();
+        auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+        // Anthropic doesn't have a /models list endpoint that's as simple as OpenAI
+        // Use a simple test by checking if the API is reachable
+        // We can try listing models if available
+        auto res = client->Get(path_prefix + "/models", GetHeaders());
+
+        if (!res) {
+            return Result<bool>::Error("Connection failed: " + httplib::to_string(res.error()));
+        }
+
+        // Accept 200 or 401 (unauthorized but reachable) as healthy
+        if (res->status == 200 || res->status == 401) {
+            return Result<bool>::Ok(true);
+        }
+
+        return Result<bool>::Error("Health check failed with status: " + std::to_string(res->status));
+    } catch (const std::exception& e) {
+        return Result<bool>::Error(std::string("Health check exception: ") + e.what());
+    }
+}
+
+auto AnthropicProvider::ListModels() -> Result<std::vector<ModelInfo>> {
+    try {
+        auto client = CreateClient();
+        auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+        auto res = client->Get(path_prefix + "/models", GetHeaders());
+
+        if (!res) {
+            return Result<std::vector<ModelInfo>>::Error(
+                "Connection failed: " + httplib::to_string(res.error()));
+        }
+
+        std::vector<ModelInfo> models;
+
+        if (res->status == 200) {
+            auto json = nlohmann::json::parse(res->body);
+
+            if (json.contains("data") && json["data"].is_array()) {
+                for (const auto& model : json["data"]) {
+                    ModelInfo info;
+                    info.id = model.value("id", "");
+                    info.name = model.value("display_name", info.id);
+
+                    // Parse context window if available
+                    if (model.contains("context_window")) {
+                        info.context_length = model["context_window"].get<int64_t>();
+                    }
+
+                    // Check capabilities
+                    if (model.contains("capabilities")) {
+                        const auto& caps = model["capabilities"];
+                        info.supports_tools = caps.value("tool_use", false);
+                        info.supports_vision = caps.value("vision", false);
+                    } else {
+                        // Default capabilities for known Claude models
+                        info.supports_tools = true;
+                        info.supports_vision = info.id.find("claude-3") != std::string::npos;
+                    }
+
+                    models.push_back(std::move(info));
+                }
+            }
+        } else {
+            // If model listing fails, return known models
+            // Anthropic's model list API requires specific access
+            models = {
+                {"claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet", "", 200000, true, true},
+                {"claude-3-5-haiku-20241022", "Claude 3.5 Haiku", "", 200000, true, true},
+                {"claude-3-opus-20240229", "Claude 3 Opus", "", 200000, true, true},
+                {"claude-3-sonnet-20240229", "Claude 3 Sonnet", "", 200000, true, true},
+                {"claude-3-haiku-20240307", "Claude 3 Haiku", "", 200000, true, true},
+            };
+        }
+
+        return Result<std::vector<ModelInfo>>::Ok(std::move(models));
+    } catch (const nlohmann::json::exception& e) {
+        return Result<std::vector<ModelInfo>>::Error(
+            std::string("JSON parse error: ") + e.what());
+    } catch (const std::exception& e) {
+        return Result<std::vector<ModelInfo>>::Error(
+            std::string("Exception: ") + e.what());
+    }
+}
+
+auto AnthropicProvider::BuildMessagesRequestBody(const ChatRequest& request, bool stream)
+    -> std::string {
+    nlohmann::json body;
+    body["model"] = request.model;
+    body["stream"] = stream;
+
+    if (request.max_tokens > 0) {
+        body["max_tokens"] = request.max_tokens;
+    } else {
+        body["max_tokens"] = 4096;  // Anthropic requires max_tokens
+    }
+
+    if (request.temperature >= 0) {
+        body["temperature"] = request.temperature;
+    }
+
+    // Add system prompt if present
+    if (request.system_prompt.has_value() && !request.system_prompt->empty()) {
+        body["system"] = *request.system_prompt;
+    }
+
+    // Build messages array
+    nlohmann::json messages = nlohmann::json::array();
+
+    for (const auto& msg : request.messages) {
+        // Skip system messages (handled above)
+        if (msg.role == MessageRole::kSystem) {
+            continue;
+        }
+
+        nlohmann::json message;
+        message["role"] = RoleToString(msg.role);
+
+        // Handle content parts
+        if (msg.content.size() == 1 && msg.content[0].type == ContentPart::Type::kText) {
+            // Simple text content
+            message["content"] = msg.content[0].text;
+        } else {
+            // Multi-part content
+            nlohmann::json content_parts = nlohmann::json::array();
+
+            for (const auto& part : msg.content) {
+                nlohmann::json content_part;
+
+                switch (part.type) {
+                    case ContentPart::Type::kText:
+                        content_part["type"] = "text";
+                        content_part["text"] = part.text;
+                        break;
+                    case ContentPart::Type::kImage: {
+                        content_part["type"] = "image";
+                        // Check if it's a URL or base64
+                        if (part.image_url.starts_with("data:")) {
+                            // Base64 data URI
+                            auto comma_pos = part.image_url.find(',');
+                            if (comma_pos != std::string::npos) {
+                                content_part["source"] = {
+                                    {"type", "base64"},
+                                    {"media_type", part.media_type.empty() ? "image/png" : part.media_type},
+                                    {"data", part.image_url.substr(comma_pos + 1)}
+                                };
+                            }
+                        } else {
+                            // URL
+                            content_part["source"] = {
+                                {"type", "url"},
+                                {"url", part.image_url}
+                            };
+                        }
+                        break;
+                    }
+                    case ContentPart::Type::kToolUse:
+                        content_part["type"] = "tool_use";
+                        content_part["id"] = part.tool_id;
+                        content_part["name"] = part.tool_name;
+                        try {
+                            content_part["input"] = nlohmann::json::parse(part.tool_arguments);
+                        } catch (...) {
+                            content_part["input"] = nlohmann::json::object();
+                        }
+                        break;
+                    case ContentPart::Type::kToolResult:
+                        content_part["type"] = "tool_result";
+                        content_part["tool_use_id"] = part.tool_id;
+                        content_part["content"] = part.text;
+                        if (part.is_error) {
+                            content_part["is_error"] = true;
+                        }
+                        break;
+                }
+
+                content_parts.push_back(content_part);
+            }
+
+            message["content"] = content_parts;
+        }
+
+        messages.push_back(message);
+    }
+
+    body["messages"] = messages;
+
+    // Add tools if present
+    if (!request.tools.empty()) {
+        nlohmann::json tools = nlohmann::json::array();
+        for (const auto& tool : request.tools) {
+            nlohmann::json tool_json;
+            tool_json["name"] = tool.name;
+            tool_json["description"] = tool.description;
+
+            // Parse input schema
+            if (!tool.input_schema.empty()) {
+                try {
+                    tool_json["input_schema"] = nlohmann::json::parse(tool.input_schema);
+                } catch (...) {
+                    tool_json["input_schema"] = {{"type", "object"}, {"properties", nlohmann::json::object()}};
+                }
+            } else {
+                tool_json["input_schema"] = {{"type", "object"}, {"properties", nlohmann::json::object()}};
+            }
+
+            tools.push_back(tool_json);
+        }
+        body["tools"] = tools;
+    }
+
+    return body.dump();
+}
+
+auto AnthropicProvider::ParseMessagesResponse(const std::string& body) -> Result<ChatResponse> {
+    try {
+        auto json = nlohmann::json::parse(body);
+
+        // Check for error response
+        if (json.contains("error")) {
+            std::string error_msg = json["error"].value("message", "Unknown error");
+            return Result<ChatResponse>::Error(error_msg);
+        }
+
+        ChatResponse response;
+
+        // Parse content blocks
+        if (json.contains("content") && json["content"].is_array()) {
+            for (const auto& block : json["content"]) {
+                std::string type = block.value("type", "");
+
+                if (type == "text") {
+                    if (!response.content.empty()) {
+                        response.content += "\n";
+                    }
+                    response.content += block.value("text", "");
+                } else if (type == "tool_use") {
+                    ToolCall call;
+                    call.id = block.value("id", "");
+                    call.name = block.value("name", "");
+                    if (block.contains("input")) {
+                        call.arguments = block["input"].dump();
+                    }
+                    response.tool_calls.push_back(std::move(call));
+                }
+            }
+        }
+
+        // Parse stop reason
+        std::string stop_reason = json.value("stop_reason", "end_turn");
+        if (stop_reason == "end_turn" || stop_reason == "stop_sequence") {
+            response.finish_reason = FinishReason::kStop;
+        } else if (stop_reason == "max_tokens") {
+            response.finish_reason = FinishReason::kLength;
+        } else if (stop_reason == "tool_use") {
+            response.finish_reason = FinishReason::kToolUse;
+        }
+
+        // Parse usage
+        if (json.contains("usage")) {
+            const auto& usage = json["usage"];
+            response.usage.prompt_tokens = usage.value("input_tokens", 0);
+            response.usage.completion_tokens = usage.value("output_tokens", 0);
+            response.usage.total_tokens =
+                response.usage.prompt_tokens + response.usage.completion_tokens;
+        }
+
+        return Result<ChatResponse>::Ok(std::move(response));
+    } catch (const nlohmann::json::exception& e) {
+        return Result<ChatResponse>::Error(std::string("JSON parse error: ") + e.what());
+    }
+}
+
+auto AnthropicProvider::ParseStreamEvent(const std::string& event_type, const std::string& data)
+    -> std::optional<StreamChunk> {
+    try {
+        StreamChunk chunk;
+
+        if (event_type == "message_stop") {
+            chunk.is_done = true;
+            return chunk;
+        }
+
+        auto json = nlohmann::json::parse(data);
+
+        if (event_type == "content_block_delta") {
+            if (json.contains("delta")) {
+                const auto& delta = json["delta"];
+                std::string type = delta.value("type", "");
+
+                if (type == "text_delta") {
+                    chunk.content_delta = delta.value("text", "");
+                } else if (type == "input_json_delta") {
+                    // Partial tool input JSON
+                    // This is accumulated by the caller
+                }
+            }
+        } else if (event_type == "content_block_start") {
+            if (json.contains("content_block")) {
+                const auto& block = json["content_block"];
+                std::string type = block.value("type", "");
+
+                if (type == "tool_use") {
+                    ToolCall call;
+                    call.id = block.value("id", "");
+                    call.name = block.value("name", "");
+                    chunk.tool_call = call;
+                }
+            }
+        } else if (event_type == "message_delta") {
+            if (json.contains("delta")) {
+                const auto& delta = json["delta"];
+
+                // Parse stop reason
+                std::string stop_reason = delta.value("stop_reason", "");
+                if (stop_reason == "end_turn" || stop_reason == "stop_sequence") {
+                    chunk.finish_reason = FinishReason::kStop;
+                } else if (stop_reason == "max_tokens") {
+                    chunk.finish_reason = FinishReason::kLength;
+                } else if (stop_reason == "tool_use") {
+                    chunk.finish_reason = FinishReason::kToolUse;
+                }
+            }
+
+            // Parse usage from message_delta
+            if (json.contains("usage")) {
+                const auto& usage = json["usage"];
+                UsageInfo info;
+                info.prompt_tokens = usage.value("input_tokens", 0);
+                info.completion_tokens = usage.value("output_tokens", 0);
+                info.total_tokens = info.prompt_tokens + info.completion_tokens;
+                chunk.usage = info;
+            }
+        }
+
+        return chunk;
+    } catch (const nlohmann::json::exception& e) {
+        spdlog::debug("Failed to parse Anthropic stream event: {} - data: {}", e.what(), data);
+        return std::nullopt;
+    }
+}
+
+auto AnthropicProvider::Chat(const ChatRequest& request) -> Result<ChatResponse> {
+    try {
+        auto client = CreateClient();
+        auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+        std::string body = BuildMessagesRequestBody(request, false);
+
+        auto res = client->Post(path_prefix + "/messages", GetHeaders(), body,
+                                "application/json");
+
+        if (!res) {
+            return Result<ChatResponse>::Error(
+                "Connection failed: " + httplib::to_string(res.error()));
+        }
+
+        if (res->status != 200) {
+            // Try to extract error message
+            try {
+                auto err_json = nlohmann::json::parse(res->body);
+                if (err_json.contains("error")) {
+                    return Result<ChatResponse>::Error(
+                        err_json["error"].value("message", "HTTP " + std::to_string(res->status)));
+                }
+            } catch (...) {
+            }
+            return Result<ChatResponse>::Error(
+                "Chat failed: HTTP " + std::to_string(res->status));
+        }
+
+        return ParseMessagesResponse(res->body);
+    } catch (const std::exception& e) {
+        return Result<ChatResponse>::Error(std::string("Exception: ") + e.what());
+    }
+}
+
+auto AnthropicProvider::ChatStream(const ChatRequest& request, StreamCallback callback)
+    -> Result<ChatResponse> {
+    try {
+        auto client = CreateClient();
+        auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+        std::string body = BuildMessagesRequestBody(request, true);
+
+        // For accumulating the full response
+        ChatResponse final_response;
+        std::string accumulated_content;
+        std::vector<ToolCall> tool_calls;
+        std::string current_tool_arguments;
+        int current_tool_index = -1;
+
+        std::string current_event_type;
+        std::string current_data;
+
+        auto content_receiver = [&](const char* data, size_t data_length) -> bool {
+            std::string chunk_data(data, data_length);
+
+            // SSE format: "event: type\ndata: {...}\n\n"
+            std::istringstream stream(chunk_data);
+            std::string line;
+
+            while (std::getline(stream, line)) {
+                // Remove \r if present
+                if (!line.empty() && line.back() == '\r') {
+                    line.pop_back();
+                }
+
+                if (line.empty()) {
+                    // End of event, process it
+                    if (!current_event_type.empty() && !current_data.empty()) {
+                        auto chunk = ParseStreamEvent(current_event_type, current_data);
+                        if (chunk) {
+                            if (chunk->is_done) {
+                                StreamChunk done_chunk;
+                                done_chunk.is_done = true;
+                                done_chunk.usage = final_response.usage;
+                                done_chunk.finish_reason = final_response.finish_reason;
+                                callback(done_chunk);
+                                return true;
+                            }
+
+                            // Accumulate content
+                            if (!chunk->content_delta.empty()) {
+                                accumulated_content += chunk->content_delta;
+                            }
+
+                            // Handle tool calls
+                            if (chunk->tool_call) {
+                                tool_calls.push_back(*chunk->tool_call);
+                                current_tool_index = static_cast<int>(tool_calls.size()) - 1;
+                                current_tool_arguments.clear();
+                            }
+
+                            // Update usage and finish reason if present
+                            if (chunk->usage) {
+                                final_response.usage = *chunk->usage;
+                            }
+                            if (chunk->finish_reason) {
+                                final_response.finish_reason = *chunk->finish_reason;
+                            }
+
+                            // Call the callback
+                            if (!callback(*chunk)) {
+                                return false;
+                            }
+                        }
+                    }
+                    current_event_type.clear();
+                    current_data.clear();
+                    continue;
+                }
+
+                if (line.starts_with("event: ")) {
+                    current_event_type = line.substr(7);
+                } else if (line.starts_with("data: ")) {
+                    current_data = line.substr(6);
+
+                    // Handle input_json_delta for tool arguments
+                    if (current_event_type == "content_block_delta" && current_tool_index >= 0) {
+                        try {
+                            auto delta_json = nlohmann::json::parse(current_data);
+                            if (delta_json.contains("delta") &&
+                                delta_json["delta"].value("type", "") == "input_json_delta") {
+                                current_tool_arguments +=
+                                    delta_json["delta"].value("partial_json", "");
+                            }
+                        } catch (...) {
+                        }
+                    }
+                }
+            }
+            return true;
+        };
+
+        auto res = client->Post(
+            path_prefix + "/messages", GetHeaders(), body, "application/json",
+            content_receiver);
+
+        if (!res) {
+            return Result<ChatResponse>::Error(
+                "Connection failed: " + httplib::to_string(res.error()));
+        }
+
+        if (res->status != 200) {
+            return Result<ChatResponse>::Error(
+                "Stream failed: HTTP " + std::to_string(res->status));
+        }
+
+        // Build final response
+        final_response.content = accumulated_content;
+
+        // Finalize tool arguments
+        for (size_t i = 0; i < tool_calls.size(); ++i) {
+            if (static_cast<int>(i) == current_tool_index && !current_tool_arguments.empty()) {
+                tool_calls[i].arguments = current_tool_arguments;
+            }
+        }
+        final_response.tool_calls = tool_calls;
+
+        return Result<ChatResponse>::Ok(std::move(final_response));
+    } catch (const std::exception& e) {
+        return Result<ChatResponse>::Error(std::string("Exception: ") + e.what());
+    }
+}
+
+}  // namespace smartbotic::llm::provider

+ 556 - 0
llm/src/provider/openai_provider.cpp

@@ -0,0 +1,556 @@
+#include "smartbotic/llm/provider/openai_provider.hpp"
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include <sstream>
+
+namespace smartbotic::llm::provider {
+
+namespace {
+
+constexpr const char* kDefaultBaseUrl = "https://api.openai.com/v1";
+
+auto ExtractHostAndPath(const std::string& url) -> std::pair<std::string, std::string> {
+    std::string host;
+    std::string path_prefix;
+
+    // Remove protocol
+    std::string work = url;
+    if (work.starts_with("https://")) {
+        work = work.substr(8);
+    } else if (work.starts_with("http://")) {
+        work = work.substr(7);
+    }
+
+    // Find first slash for path
+    auto slash_pos = work.find('/');
+    if (slash_pos != std::string::npos) {
+        host = work.substr(0, slash_pos);
+        path_prefix = work.substr(slash_pos);
+        // Remove trailing slash
+        if (!path_prefix.empty() && path_prefix.back() == '/') {
+            path_prefix.pop_back();
+        }
+    } else {
+        host = work;
+    }
+
+    return {host, path_prefix};
+}
+
+auto RoleToString(MessageRole role) -> std::string {
+    switch (role) {
+        case MessageRole::kSystem:
+            return "system";
+        case MessageRole::kUser:
+            return "user";
+        case MessageRole::kAssistant:
+            return "assistant";
+        case MessageRole::kTool:
+            return "tool";
+    }
+    return "user";
+}
+
+}  // namespace
+
+OpenAIProvider::OpenAIProvider(const ProviderConfig& config) : config_(config) {
+    if (config_.base_url.empty()) {
+        config_.base_url = kDefaultBaseUrl;
+    }
+}
+
+void OpenAIProvider::UpdateConfig(const ProviderConfig& config) {
+    std::lock_guard<std::mutex> lock(mutex_);
+    config_ = config;
+    if (config_.base_url.empty()) {
+        config_.base_url = kDefaultBaseUrl;
+    }
+}
+
+auto OpenAIProvider::CreateClient() -> std::unique_ptr<httplib::Client> {
+    auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+    // Determine if HTTPS
+    bool use_https = config_.base_url.starts_with("https://");
+    std::string full_host = (use_https ? "https://" : "http://") + host;
+
+    auto client = std::make_unique<httplib::Client>(full_host);
+    client->set_connection_timeout(30);
+    client->set_read_timeout(120);  // LLM responses can be slow
+    client->set_write_timeout(30);
+
+    return client;
+}
+
+auto OpenAIProvider::GetHeaders() -> httplib::Headers {
+    httplib::Headers headers;
+    headers.emplace("Content-Type", "application/json");
+    if (!config_.api_key.empty()) {
+        headers.emplace("Authorization", "Bearer " + config_.api_key);
+    }
+    return headers;
+}
+
+auto OpenAIProvider::HealthCheck() -> Result<bool> {
+    try {
+        auto client = CreateClient();
+        auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+        auto res = client->Get(path_prefix + "/models", GetHeaders());
+
+        if (!res) {
+            return Result<bool>::Error("Connection failed: " + httplib::to_string(res.error()));
+        }
+
+        if (res->status == 200) {
+            return Result<bool>::Ok(true);
+        }
+
+        return Result<bool>::Error("Health check failed with status: " + std::to_string(res->status));
+    } catch (const std::exception& e) {
+        return Result<bool>::Error(std::string("Health check exception: ") + e.what());
+    }
+}
+
+auto OpenAIProvider::ListModels() -> Result<std::vector<ModelInfo>> {
+    try {
+        auto client = CreateClient();
+        auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+        auto res = client->Get(path_prefix + "/models", GetHeaders());
+
+        if (!res) {
+            return Result<std::vector<ModelInfo>>::Error(
+                "Connection failed: " + httplib::to_string(res.error()));
+        }
+
+        if (res->status != 200) {
+            return Result<std::vector<ModelInfo>>::Error(
+                "Failed to list models: HTTP " + std::to_string(res->status));
+        }
+
+        auto json = nlohmann::json::parse(res->body);
+        std::vector<ModelInfo> models;
+
+        if (json.contains("data") && json["data"].is_array()) {
+            for (const auto& model : json["data"]) {
+                ModelInfo info;
+                info.id = model.value("id", "");
+                info.name = info.id;  // OpenAI uses id as name
+                info.owner = model.value("owned_by", "");
+
+                // OpenAI doesn't return context_length in the list, use known defaults
+                if (info.id.find("gpt-4") != std::string::npos) {
+                    info.context_length = 128000;
+                    info.supports_tools = true;
+                    info.supports_vision = info.id.find("vision") != std::string::npos ||
+                                           info.id.find("-o") != std::string::npos;
+                } else if (info.id.find("gpt-3.5") != std::string::npos) {
+                    info.context_length = 16384;
+                    info.supports_tools = true;
+                    info.supports_vision = false;
+                }
+
+                models.push_back(std::move(info));
+            }
+        }
+
+        return Result<std::vector<ModelInfo>>::Ok(std::move(models));
+    } catch (const nlohmann::json::exception& e) {
+        return Result<std::vector<ModelInfo>>::Error(
+            std::string("JSON parse error: ") + e.what());
+    } catch (const std::exception& e) {
+        return Result<std::vector<ModelInfo>>::Error(
+            std::string("Exception: ") + e.what());
+    }
+}
+
+auto OpenAIProvider::BuildChatRequestBody(const ChatRequest& request, bool stream) -> std::string {
+    nlohmann::json body;
+    body["model"] = request.model;
+    body["stream"] = stream;
+
+    if (request.max_tokens > 0) {
+        body["max_tokens"] = request.max_tokens;
+    }
+    if (request.temperature >= 0) {
+        body["temperature"] = request.temperature;
+    }
+
+    // Build messages array
+    nlohmann::json messages = nlohmann::json::array();
+
+    // Add system prompt if present
+    if (request.system_prompt.has_value() && !request.system_prompt->empty()) {
+        messages.push_back({{"role", "system"}, {"content", *request.system_prompt}});
+    }
+
+    // Add conversation messages
+    for (const auto& msg : request.messages) {
+        nlohmann::json message;
+        message["role"] = RoleToString(msg.role);
+
+        // Handle content parts
+        if (msg.content.size() == 1 && msg.content[0].type == ContentPart::Type::kText) {
+            // Simple text content
+            message["content"] = msg.content[0].text;
+        } else {
+            // Multi-part content (for vision, tool results, etc.)
+            nlohmann::json content_parts = nlohmann::json::array();
+
+            for (const auto& part : msg.content) {
+                nlohmann::json content_part;
+
+                switch (part.type) {
+                    case ContentPart::Type::kText:
+                        content_part["type"] = "text";
+                        content_part["text"] = part.text;
+                        break;
+                    case ContentPart::Type::kImage:
+                        content_part["type"] = "image_url";
+                        content_part["image_url"] = {{"url", part.image_url}};
+                        break;
+                    case ContentPart::Type::kToolUse:
+                        // Tool use is handled via tool_calls in assistant messages
+                        continue;
+                    case ContentPart::Type::kToolResult:
+                        // Tool results become separate messages with role "tool"
+                        content_part["type"] = "text";
+                        content_part["text"] = part.text;
+                        break;
+                }
+
+                content_parts.push_back(content_part);
+            }
+
+            if (!content_parts.empty()) {
+                message["content"] = content_parts;
+            }
+        }
+
+        // Add tool_call_id for tool messages
+        if (msg.role == MessageRole::kTool && !msg.content.empty()) {
+            for (const auto& part : msg.content) {
+                if (part.type == ContentPart::Type::kToolResult) {
+                    message["tool_call_id"] = part.tool_id;
+                    message["content"] = part.text;
+                    break;
+                }
+            }
+        }
+
+        messages.push_back(message);
+    }
+
+    body["messages"] = messages;
+
+    // Add tools if present
+    if (!request.tools.empty()) {
+        nlohmann::json tools = nlohmann::json::array();
+        for (const auto& tool : request.tools) {
+            nlohmann::json tool_json;
+            tool_json["type"] = "function";
+            tool_json["function"] = {
+                {"name", tool.name},
+                {"description", tool.description}
+            };
+
+            // Parse input schema
+            if (!tool.input_schema.empty()) {
+                try {
+                    tool_json["function"]["parameters"] = nlohmann::json::parse(tool.input_schema);
+                } catch (...) {
+                    tool_json["function"]["parameters"] = nlohmann::json::object();
+                }
+            }
+
+            tools.push_back(tool_json);
+        }
+        body["tools"] = tools;
+    }
+
+    return body.dump();
+}
+
+auto OpenAIProvider::ParseChatResponse(const std::string& body) -> Result<ChatResponse> {
+    try {
+        auto json = nlohmann::json::parse(body);
+
+        // Check for error response
+        if (json.contains("error")) {
+            std::string error_msg = json["error"].value("message", "Unknown error");
+            return Result<ChatResponse>::Error(error_msg);
+        }
+
+        ChatResponse response;
+
+        // Parse choices
+        if (json.contains("choices") && !json["choices"].empty()) {
+            const auto& choice = json["choices"][0];
+
+            // Parse message content
+            if (choice.contains("message")) {
+                const auto& message = choice["message"];
+
+                if (message.contains("content") && !message["content"].is_null()) {
+                    response.content = message["content"].get<std::string>();
+                }
+
+                // Parse tool calls
+                if (message.contains("tool_calls") && message["tool_calls"].is_array()) {
+                    for (const auto& tc : message["tool_calls"]) {
+                        ToolCall call;
+                        call.id = tc.value("id", "");
+                        if (tc.contains("function")) {
+                            call.name = tc["function"].value("name", "");
+                            call.arguments = tc["function"].value("arguments", "");
+                        }
+                        response.tool_calls.push_back(std::move(call));
+                    }
+                }
+            }
+
+            // Parse finish reason
+            std::string finish_reason = choice.value("finish_reason", "stop");
+            if (finish_reason == "stop") {
+                response.finish_reason = FinishReason::kStop;
+            } else if (finish_reason == "length") {
+                response.finish_reason = FinishReason::kLength;
+            } else if (finish_reason == "tool_calls") {
+                response.finish_reason = FinishReason::kToolUse;
+            } else if (finish_reason == "content_filter") {
+                response.finish_reason = FinishReason::kContentFilter;
+            }
+        }
+
+        // Parse usage
+        if (json.contains("usage")) {
+            const auto& usage = json["usage"];
+            response.usage.prompt_tokens = usage.value("prompt_tokens", 0);
+            response.usage.completion_tokens = usage.value("completion_tokens", 0);
+            response.usage.total_tokens = usage.value("total_tokens", 0);
+        }
+
+        return Result<ChatResponse>::Ok(std::move(response));
+    } catch (const nlohmann::json::exception& e) {
+        return Result<ChatResponse>::Error(std::string("JSON parse error: ") + e.what());
+    }
+}
+
+auto OpenAIProvider::ParseStreamChunk(const std::string& data) -> std::optional<StreamChunk> {
+    // Check for [DONE] marker
+    if (data == "[DONE]") {
+        StreamChunk chunk;
+        chunk.is_done = true;
+        return chunk;
+    }
+
+    try {
+        auto json = nlohmann::json::parse(data);
+
+        StreamChunk chunk;
+
+        if (json.contains("choices") && !json["choices"].empty()) {
+            const auto& choice = json["choices"][0];
+
+            // Parse delta content
+            if (choice.contains("delta")) {
+                const auto& delta = choice["delta"];
+
+                if (delta.contains("content") && !delta["content"].is_null()) {
+                    chunk.content_delta = delta["content"].get<std::string>();
+                }
+
+                // Parse tool calls delta
+                if (delta.contains("tool_calls") && delta["tool_calls"].is_array()) {
+                    for (const auto& tc : delta["tool_calls"]) {
+                        ToolCall call;
+                        call.id = tc.value("id", "");
+                        if (tc.contains("function")) {
+                            call.name = tc["function"].value("name", "");
+                            call.arguments = tc["function"].value("arguments", "");
+                        }
+                        chunk.tool_call = call;
+                        break;  // Only handle first tool call in delta
+                    }
+                }
+            }
+
+            // Parse finish reason
+            if (choice.contains("finish_reason") && !choice["finish_reason"].is_null()) {
+                std::string finish_reason = choice["finish_reason"].get<std::string>();
+                if (finish_reason == "stop") {
+                    chunk.finish_reason = FinishReason::kStop;
+                } else if (finish_reason == "length") {
+                    chunk.finish_reason = FinishReason::kLength;
+                } else if (finish_reason == "tool_calls") {
+                    chunk.finish_reason = FinishReason::kToolUse;
+                } else if (finish_reason == "content_filter") {
+                    chunk.finish_reason = FinishReason::kContentFilter;
+                }
+            }
+        }
+
+        // Parse usage (present in final chunk with stream_options include_usage)
+        if (json.contains("usage")) {
+            const auto& usage = json["usage"];
+            UsageInfo info;
+            info.prompt_tokens = usage.value("prompt_tokens", 0);
+            info.completion_tokens = usage.value("completion_tokens", 0);
+            info.total_tokens = usage.value("total_tokens", 0);
+            chunk.usage = info;
+        }
+
+        return chunk;
+    } catch (const nlohmann::json::exception& e) {
+        spdlog::debug("Failed to parse stream chunk: {} - data: {}", e.what(), data);
+        return std::nullopt;
+    }
+}
+
+auto OpenAIProvider::Chat(const ChatRequest& request) -> Result<ChatResponse> {
+    try {
+        auto client = CreateClient();
+        auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+        std::string body = BuildChatRequestBody(request, false);
+
+        auto res = client->Post(path_prefix + "/chat/completions", GetHeaders(), body,
+                                "application/json");
+
+        if (!res) {
+            return Result<ChatResponse>::Error(
+                "Connection failed: " + httplib::to_string(res.error()));
+        }
+
+        if (res->status != 200) {
+            // Try to extract error message
+            try {
+                auto err_json = nlohmann::json::parse(res->body);
+                if (err_json.contains("error")) {
+                    return Result<ChatResponse>::Error(
+                        err_json["error"].value("message", "HTTP " + std::to_string(res->status)));
+                }
+            } catch (...) {
+            }
+            return Result<ChatResponse>::Error(
+                "Chat failed: HTTP " + std::to_string(res->status));
+        }
+
+        return ParseChatResponse(res->body);
+    } catch (const std::exception& e) {
+        return Result<ChatResponse>::Error(std::string("Exception: ") + e.what());
+    }
+}
+
+auto OpenAIProvider::ChatStream(const ChatRequest& request, StreamCallback callback)
+    -> Result<ChatResponse> {
+    try {
+        auto client = CreateClient();
+        auto [host, path_prefix] = ExtractHostAndPath(config_.base_url);
+
+        std::string body = BuildChatRequestBody(request, true);
+
+        // For accumulating the full response
+        ChatResponse final_response;
+        std::string accumulated_content;
+        std::unordered_map<int, ToolCall> tool_calls;  // index -> ToolCall
+        int last_tool_index = -1;
+
+        auto content_receiver = [&](const char* data, size_t data_length) -> bool {
+            std::string chunk_data(data, data_length);
+
+            // SSE format: "data: {...}\n\n"
+            std::istringstream stream(chunk_data);
+            std::string line;
+
+            while (std::getline(stream, line)) {
+                // Remove \r if present
+                if (!line.empty() && line.back() == '\r') {
+                    line.pop_back();
+                }
+
+                if (line.empty()) {
+                    continue;
+                }
+
+                if (line.starts_with("data: ")) {
+                    std::string json_data = line.substr(6);
+
+                    auto chunk = ParseStreamChunk(json_data);
+                    if (chunk) {
+                        if (chunk->is_done) {
+                            StreamChunk done_chunk;
+                            done_chunk.is_done = true;
+                            done_chunk.usage = final_response.usage;
+                            done_chunk.finish_reason = final_response.finish_reason;
+                            callback(done_chunk);
+                            return true;
+                        }
+
+                        // Accumulate content
+                        if (!chunk->content_delta.empty()) {
+                            accumulated_content += chunk->content_delta;
+                        }
+
+                        // Accumulate tool calls
+                        if (chunk->tool_call) {
+                            // Tool calls come with an index in the delta
+                            // For now, just append
+                            if (!chunk->tool_call->id.empty()) {
+                                last_tool_index = static_cast<int>(tool_calls.size());
+                                tool_calls[last_tool_index] = *chunk->tool_call;
+                            } else if (last_tool_index >= 0) {
+                                // Append arguments to last tool call
+                                auto& last = tool_calls[last_tool_index];
+                                last.arguments += chunk->tool_call->arguments;
+                            }
+                        }
+
+                        // Update usage and finish reason if present
+                        if (chunk->usage) {
+                            final_response.usage = *chunk->usage;
+                        }
+                        if (chunk->finish_reason) {
+                            final_response.finish_reason = *chunk->finish_reason;
+                        }
+
+                        // Call the callback
+                        if (!callback(*chunk)) {
+                            return false;  // Client wants to stop
+                        }
+                    }
+                }
+            }
+            return true;
+        };
+
+        auto res = client->Post(
+            path_prefix + "/chat/completions", GetHeaders(), body, "application/json",
+            content_receiver);
+
+        if (!res) {
+            return Result<ChatResponse>::Error(
+                "Connection failed: " + httplib::to_string(res.error()));
+        }
+
+        if (res->status != 200) {
+            return Result<ChatResponse>::Error(
+                "Stream failed: HTTP " + std::to_string(res->status));
+        }
+
+        // Build final response
+        final_response.content = accumulated_content;
+        for (const auto& [idx, tc] : tool_calls) {
+            final_response.tool_calls.push_back(tc);
+        }
+
+        return Result<ChatResponse>::Ok(std::move(final_response));
+    } catch (const std::exception& e) {
+        return Result<ChatResponse>::Error(std::string("Exception: ") + e.what());
+    }
+}
+
+}  // namespace smartbotic::llm::provider

+ 103 - 0
llm/src/provider/provider_factory.cpp

@@ -0,0 +1,103 @@
+#include "smartbotic/llm/provider/provider_factory.hpp"
+
+#include "smartbotic/llm/provider/anthropic_provider.hpp"
+#include "smartbotic/llm/provider/openai_provider.hpp"
+
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::llm::provider {
+
+auto ProviderTypeToString(ProviderType type) -> std::string {
+    switch (type) {
+        case ProviderType::kOpenAI:
+            return "openai";
+        case ProviderType::kAnthropic:
+            return "anthropic";
+    }
+    return "unknown";
+}
+
+auto ParseProviderType(const std::string& str) -> std::optional<ProviderType> {
+    if (str == "openai" || str == "OPENAI" || str == "OpenAI") {
+        return ProviderType::kOpenAI;
+    }
+    if (str == "anthropic" || str == "ANTHROPIC" || str == "Anthropic") {
+        return ProviderType::kAnthropic;
+    }
+    return std::nullopt;
+}
+
+auto ProviderFactory::CreateProvider(const ProviderConfig& config)
+    -> std::unique_ptr<IProvider> {
+    switch (config.type) {
+        case ProviderType::kOpenAI:
+            return std::make_unique<OpenAIProvider>(config);
+        case ProviderType::kAnthropic:
+            return std::make_unique<AnthropicProvider>(config);
+    }
+    spdlog::error("Unknown provider type: {}", static_cast<int>(config.type));
+    return nullptr;
+}
+
+auto ProviderFactory::GetOrCreate(const ProviderConfig& config) -> std::shared_ptr<IProvider> {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    auto it = providers_.find(config.id);
+    if (it != providers_.end()) {
+        // Update config if it exists
+        it->second->UpdateConfig(config);
+        return it->second;
+    }
+
+    // Create new provider
+    auto provider = CreateProvider(config);
+    if (!provider) {
+        return nullptr;
+    }
+
+    auto shared = std::shared_ptr<IProvider>(std::move(provider));
+    providers_[config.id] = shared;
+    spdlog::debug("Created provider: {} ({})", config.name, config.id);
+    return shared;
+}
+
+auto ProviderFactory::Get(const std::string& provider_id) -> std::shared_ptr<IProvider> {
+    std::lock_guard<std::mutex> lock(mutex_);
+    auto it = providers_.find(provider_id);
+    if (it != providers_.end()) {
+        return it->second;
+    }
+    return nullptr;
+}
+
+void ProviderFactory::UpdateProvider(const ProviderConfig& config) {
+    std::lock_guard<std::mutex> lock(mutex_);
+    auto it = providers_.find(config.id);
+    if (it != providers_.end()) {
+        it->second->UpdateConfig(config);
+        spdlog::debug("Updated provider config: {}", config.id);
+    }
+}
+
+void ProviderFactory::RemoveProvider(const std::string& provider_id) {
+    std::lock_guard<std::mutex> lock(mutex_);
+    providers_.erase(provider_id);
+    spdlog::debug("Removed provider: {}", provider_id);
+}
+
+void ProviderFactory::Clear() {
+    std::lock_guard<std::mutex> lock(mutex_);
+    providers_.clear();
+}
+
+auto ProviderFactory::GetAll() -> std::vector<std::shared_ptr<IProvider>> {
+    std::lock_guard<std::mutex> lock(mutex_);
+    std::vector<std::shared_ptr<IProvider>> result;
+    result.reserve(providers_.size());
+    for (const auto& [id, provider] : providers_) {
+        result.push_back(provider);
+    }
+    return result;
+}
+
+}  // namespace smartbotic::llm::provider

+ 633 - 0
llm/src/session/database_session_store.cpp

@@ -0,0 +1,633 @@
+#include "smartbotic/llm/session/database_session_store.hpp"
+
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::llm::session {
+
+namespace {
+
+[[maybe_unused]] auto SetStringValue(::smartbotic::database::Value* value, const std::string& str) {
+    value->set_string_value(str);
+}
+
+[[maybe_unused]] auto SetIntValue(::smartbotic::database::Value* value, int64_t val) {
+    value->set_int_value(val);
+}
+
+[[maybe_unused]] auto SetBoolValue(::smartbotic::database::Value* value, bool val) {
+    value->set_bool_value(val);
+}
+
+[[maybe_unused]] auto GetStringValue(const ::smartbotic::database::Value& value) -> std::string {
+    if (value.has_string_value()) {
+        return value.string_value();
+    }
+    return "";
+}
+
+[[maybe_unused]] auto GetIntValue(const ::smartbotic::database::Value& value) -> int64_t {
+    if (value.has_int_value()) {
+        return value.int_value();
+    }
+    return 0;
+}
+
+[[maybe_unused]] auto GetBoolValue(const ::smartbotic::database::Value& value) -> bool {
+    if (value.has_bool_value()) {
+        return value.bool_value();
+    }
+    return false;
+}
+
+}  // namespace
+
+DatabaseSessionStore::DatabaseSessionStore(const DatabaseSessionStoreConfig& config)
+    : config_(config) {}
+
+auto DatabaseSessionStore::Connect() -> bool {
+    if (channel_) {
+        auto state = channel_->GetState(false);
+        if (state == GRPC_CHANNEL_READY || state == GRPC_CHANNEL_IDLE) {
+            return true;
+        }
+    }
+
+    spdlog::info("Connecting to database at {}", config_.database_address);
+    channel_ = grpc::CreateChannel(config_.database_address, grpc::InsecureChannelCredentials());
+
+    // Wait for connection
+    auto deadline = std::chrono::system_clock::now() + std::chrono::seconds(10);
+    if (!channel_->WaitForConnected(deadline)) {
+        spdlog::error("Failed to connect to database");
+        return false;
+    }
+
+    document_stub_ = ::smartbotic::database::DocumentService::NewStub(channel_);
+    collection_stub_ = ::smartbotic::database::CollectionService::NewStub(channel_);
+    query_stub_ = ::smartbotic::database::QueryService::NewStub(channel_);
+
+    return true;
+}
+
+auto DatabaseSessionStore::Initialize() -> bool {
+    if (initialized_) {
+        return true;
+    }
+
+    if (!Connect()) {
+        return false;
+    }
+
+    // Ensure collection exists
+    grpc::ClientContext ctx;
+    ::smartbotic::database::CreateCollectionRequest request;
+    request.set_name(config_.collection_name);
+
+    ::smartbotic::database::CollectionMetadata response;
+    auto status = collection_stub_->CreateCollection(&ctx, request, &response);
+
+    // Collection might already exist, which is fine
+    if (!status.ok() && status.error_code() != grpc::StatusCode::ALREADY_EXISTS) {
+        spdlog::warn("Failed to create collection {}: {}", config_.collection_name,
+                     status.error_message());
+        // Continue anyway - collection might exist
+    }
+
+    initialized_ = true;
+    spdlog::info("Session store initialized with collection: {}", config_.collection_name);
+    return true;
+}
+
+auto DatabaseSessionStore::SessionToDocument(const Session& session)
+    -> ::smartbotic::database::MapValue {
+    ::smartbotic::database::MapValue data;
+    auto* fields = data.mutable_fields();
+
+    SetStringValue(&(*fields)["user_id"], session.user_id);
+    SetStringValue(&(*fields)["workspace_id"], session.workspace_id);
+    SetStringValue(&(*fields)["title"], session.title);
+    SetStringValue(&(*fields)["model_id"], session.model_id);
+    SetStringValue(&(*fields)["system_prompt"], session.system_prompt);
+    SetStringValue(&(*fields)["created_at"], TimestampToString(session.created_at));
+    SetStringValue(&(*fields)["updated_at"], TimestampToString(session.updated_at));
+
+    // Convert messages to array
+    auto* messages_array = (*fields)["messages"].mutable_array_value();
+    for (const auto& msg : session.messages) {
+        auto* msg_value = messages_array->add_values();
+        *msg_value = MessageToValue(msg);
+    }
+
+    return data;
+}
+
+auto DatabaseSessionStore::DocumentToSession(const ::smartbotic::database::MapValue& data,
+                                              const std::string& doc_id) -> Session {
+    Session session;
+    session.id = doc_id;
+
+    const auto& fields = data.fields();
+
+    if (fields.contains("user_id")) {
+        session.user_id = GetStringValue(fields.at("user_id"));
+    }
+    if (fields.contains("workspace_id")) {
+        session.workspace_id = GetStringValue(fields.at("workspace_id"));
+    }
+    if (fields.contains("title")) {
+        session.title = GetStringValue(fields.at("title"));
+    }
+    if (fields.contains("model_id")) {
+        session.model_id = GetStringValue(fields.at("model_id"));
+    }
+    if (fields.contains("system_prompt")) {
+        session.system_prompt = GetStringValue(fields.at("system_prompt"));
+    }
+
+    if (fields.contains("created_at")) {
+        auto ts = ParseTimestamp(GetStringValue(fields.at("created_at")));
+        if (ts) {
+            session.created_at = *ts;
+        }
+    }
+    if (fields.contains("updated_at")) {
+        auto ts = ParseTimestamp(GetStringValue(fields.at("updated_at")));
+        if (ts) {
+            session.updated_at = *ts;
+        }
+    }
+
+    // Parse messages
+    if (fields.contains("messages") && fields.at("messages").has_array_value()) {
+        const auto& messages_array = fields.at("messages").array_value();
+        for (const auto& msg_value : messages_array.values()) {
+            session.messages.push_back(ValueToMessage(msg_value));
+        }
+    }
+
+    return session;
+}
+
+auto DatabaseSessionStore::MessageToValue(const Message& message)
+    -> ::smartbotic::database::Value {
+    ::smartbotic::database::Value value;
+    auto* msg_map = value.mutable_map_value()->mutable_fields();
+
+    SetStringValue(&(*msg_map)["id"], message.id);
+    SetStringValue(&(*msg_map)["role"], MessageRoleToString(message.role));
+    SetStringValue(&(*msg_map)["created_at"], TimestampToString(message.created_at));
+
+    // Convert content parts to array
+    auto* content_array = (*msg_map)["content"].mutable_array_value();
+    for (const auto& part : message.content) {
+        auto* part_value = content_array->add_values();
+        auto* part_map = part_value->mutable_map_value()->mutable_fields();
+
+        switch (part.type) {
+            case ContentPartType::kText:
+                SetStringValue(&(*part_map)["type"], "text");
+                SetStringValue(&(*part_map)["text"], part.text);
+                break;
+            case ContentPartType::kImage:
+                SetStringValue(&(*part_map)["type"], "image");
+                SetStringValue(&(*part_map)["image_url"], part.image_url);
+                SetStringValue(&(*part_map)["media_type"], part.media_type);
+                break;
+            case ContentPartType::kToolUse:
+                SetStringValue(&(*part_map)["type"], "tool_use");
+                SetStringValue(&(*part_map)["tool_id"], part.tool_id);
+                SetStringValue(&(*part_map)["tool_name"], part.tool_name);
+                SetStringValue(&(*part_map)["tool_arguments"], part.tool_arguments);
+                break;
+            case ContentPartType::kToolResult:
+                SetStringValue(&(*part_map)["type"], "tool_result");
+                SetStringValue(&(*part_map)["tool_id"], part.tool_id);
+                SetStringValue(&(*part_map)["text"], part.text);
+                SetBoolValue(&(*part_map)["is_error"], part.is_error);
+                break;
+        }
+    }
+
+    // Page context
+    if (message.page_context.has_value()) {
+        auto* ctx_map = (*msg_map)["page_context"].mutable_map_value()->mutable_fields();
+        SetStringValue(&(*ctx_map)["path"], message.page_context->path);
+        SetStringValue(&(*ctx_map)["title"], message.page_context->title);
+        SetStringValue(&(*ctx_map)["workspace_id"], message.page_context->workspace_id);
+        SetStringValue(&(*ctx_map)["collection"], message.page_context->collection);
+        SetStringValue(&(*ctx_map)["view_id"], message.page_context->view_id);
+    }
+
+    return value;
+}
+
+auto DatabaseSessionStore::ValueToMessage(const ::smartbotic::database::Value& value) -> Message {
+    Message message;
+
+    if (!value.has_map_value()) {
+        return message;
+    }
+
+    const auto& fields = value.map_value().fields();
+
+    if (fields.contains("id")) {
+        message.id = GetStringValue(fields.at("id"));
+    }
+    if (fields.contains("role")) {
+        auto role = ParseMessageRole(GetStringValue(fields.at("role")));
+        if (role) {
+            message.role = *role;
+        }
+    }
+    if (fields.contains("created_at")) {
+        auto ts = ParseTimestamp(GetStringValue(fields.at("created_at")));
+        if (ts) {
+            message.created_at = *ts;
+        }
+    }
+
+    // Parse content parts
+    if (fields.contains("content") && fields.at("content").has_array_value()) {
+        for (const auto& part_value : fields.at("content").array_value().values()) {
+            if (!part_value.has_map_value()) continue;
+
+            ContentPart part;
+            const auto& part_fields = part_value.map_value().fields();
+
+            std::string type_str;
+            if (part_fields.contains("type")) {
+                type_str = GetStringValue(part_fields.at("type"));
+            }
+
+            if (type_str == "text") {
+                part.type = ContentPartType::kText;
+                if (part_fields.contains("text")) {
+                    part.text = GetStringValue(part_fields.at("text"));
+                }
+            } else if (type_str == "image") {
+                part.type = ContentPartType::kImage;
+                if (part_fields.contains("image_url")) {
+                    part.image_url = GetStringValue(part_fields.at("image_url"));
+                }
+                if (part_fields.contains("media_type")) {
+                    part.media_type = GetStringValue(part_fields.at("media_type"));
+                }
+            } else if (type_str == "tool_use") {
+                part.type = ContentPartType::kToolUse;
+                if (part_fields.contains("tool_id")) {
+                    part.tool_id = GetStringValue(part_fields.at("tool_id"));
+                }
+                if (part_fields.contains("tool_name")) {
+                    part.tool_name = GetStringValue(part_fields.at("tool_name"));
+                }
+                if (part_fields.contains("tool_arguments")) {
+                    part.tool_arguments = GetStringValue(part_fields.at("tool_arguments"));
+                }
+            } else if (type_str == "tool_result") {
+                part.type = ContentPartType::kToolResult;
+                if (part_fields.contains("tool_id")) {
+                    part.tool_id = GetStringValue(part_fields.at("tool_id"));
+                }
+                if (part_fields.contains("text")) {
+                    part.text = GetStringValue(part_fields.at("text"));
+                }
+                if (part_fields.contains("is_error")) {
+                    part.is_error = GetBoolValue(part_fields.at("is_error"));
+                }
+            }
+
+            message.content.push_back(std::move(part));
+        }
+    }
+
+    // Parse page context
+    if (fields.contains("page_context") && fields.at("page_context").has_map_value()) {
+        PageContext ctx;
+        const auto& ctx_fields = fields.at("page_context").map_value().fields();
+
+        if (ctx_fields.contains("path")) {
+            ctx.path = GetStringValue(ctx_fields.at("path"));
+        }
+        if (ctx_fields.contains("title")) {
+            ctx.title = GetStringValue(ctx_fields.at("title"));
+        }
+        if (ctx_fields.contains("workspace_id")) {
+            ctx.workspace_id = GetStringValue(ctx_fields.at("workspace_id"));
+        }
+        if (ctx_fields.contains("collection")) {
+            ctx.collection = GetStringValue(ctx_fields.at("collection"));
+        }
+        if (ctx_fields.contains("view_id")) {
+            ctx.view_id = GetStringValue(ctx_fields.at("view_id"));
+        }
+
+        message.page_context = ctx;
+    }
+
+    return message;
+}
+
+auto DatabaseSessionStore::CreateSession(const CreateSessionRequest& request)
+    -> Result<Session> {
+    if (!Connect()) {
+        return Result<Session>::Error("Not connected to database");
+    }
+
+    Session session;
+    session.id = GenerateId();
+    session.user_id = request.user_id;
+    session.workspace_id = request.workspace_id;
+    session.title = request.title.empty() ? "New Chat" : request.title;
+    session.model_id = request.model_id.empty() ? config_.default_model : request.model_id;
+    session.system_prompt = request.system_prompt;
+    session.created_at = Now();
+    session.updated_at = session.created_at;
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::CreateDocumentRequest db_request;
+    db_request.set_collection(config_.collection_name);
+    db_request.set_id(session.id);
+    *db_request.mutable_data() = SessionToDocument(session);
+
+    ::smartbotic::database::Document response;
+    auto status = document_stub_->CreateDocument(&ctx, db_request, &response);
+
+    if (!status.ok()) {
+        return Result<Session>::Error("Failed to create session: " + status.error_message());
+    }
+
+    spdlog::debug("Created session {} for user {}", session.id, session.user_id);
+    return Result<Session>::Ok(std::move(session));
+}
+
+auto DatabaseSessionStore::GetSession(const std::string& session_id,
+                                       const std::string& user_id,
+                                       bool include_messages) -> Result<Session> {
+    if (!Connect()) {
+        return Result<Session>::Error("Not connected to database");
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetDocumentRequest request;
+    request.set_collection(config_.collection_name);
+    request.set_id(session_id);
+
+    ::smartbotic::database::Document response;
+    auto status = document_stub_->GetDocument(&ctx, request, &response);
+
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return Result<Session>::Error("Session not found");
+        }
+        return Result<Session>::Error("Failed to get session: " + status.error_message());
+    }
+
+    auto session = DocumentToSession(response.data(), response.id());
+
+    // Verify ownership
+    if (session.user_id != user_id) {
+        return Result<Session>::Error("Access denied");
+    }
+
+    // Clear messages if not requested
+    if (!include_messages) {
+        session.messages.clear();
+    }
+
+    return Result<Session>::Ok(std::move(session));
+}
+
+auto DatabaseSessionStore::ListSessions(const ListSessionsRequest& request)
+    -> Result<ListSessionsResponse> {
+    if (!Connect()) {
+        return Result<ListSessionsResponse>::Error("Not connected to database");
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest query;
+    query.set_collection(config_.collection_name);
+    query.set_limit(request.page_size > 0 ? request.page_size : 50);
+
+    // Filter by user_id
+    auto* filter = query.mutable_filter()->mutable_composite();
+    filter->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* user_filter = filter->add_filters()->mutable_field();
+    user_filter->set_field("user_id");
+    user_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    user_filter->mutable_value()->set_string_value(request.user_id);
+
+    // Optional workspace filter
+    if (!request.workspace_id.empty()) {
+        auto* ws_filter = filter->add_filters()->mutable_field();
+        ws_filter->set_field("workspace_id");
+        ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        ws_filter->mutable_value()->set_string_value(request.workspace_id);
+    }
+
+    // Order by updated_at descending
+    auto* order = query.add_order_by();
+    order->set_field("updated_at");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_DESCENDING);
+
+    // Select only summary fields (no messages)
+    query.add_select_fields("user_id");
+    query.add_select_fields("workspace_id");
+    query.add_select_fields("title");
+    query.add_select_fields("model_id");
+    query.add_select_fields("created_at");
+    query.add_select_fields("updated_at");
+
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_stub_->Query(&ctx, query, &response);
+
+    if (!status.ok()) {
+        return Result<ListSessionsResponse>::Error("Failed to list sessions: " +
+                                                    status.error_message());
+    }
+
+    ListSessionsResponse result;
+    result.total_count = static_cast<int>(response.total_count());
+
+    for (const auto& doc : response.documents()) {
+        SessionSummary summary;
+        summary.id = doc.id();
+
+        const auto& fields = doc.data().fields();
+        if (fields.contains("user_id")) {
+            summary.user_id = GetStringValue(fields.at("user_id"));
+        }
+        if (fields.contains("workspace_id")) {
+            summary.workspace_id = GetStringValue(fields.at("workspace_id"));
+        }
+        if (fields.contains("title")) {
+            summary.title = GetStringValue(fields.at("title"));
+        }
+        if (fields.contains("model_id")) {
+            summary.model_id = GetStringValue(fields.at("model_id"));
+        }
+        if (fields.contains("created_at")) {
+            auto ts = ParseTimestamp(GetStringValue(fields.at("created_at")));
+            if (ts) summary.created_at = *ts;
+        }
+        if (fields.contains("updated_at")) {
+            auto ts = ParseTimestamp(GetStringValue(fields.at("updated_at")));
+            if (ts) summary.updated_at = *ts;
+        }
+
+        result.sessions.push_back(std::move(summary));
+    }
+
+    result.next_page_token = response.next_page_token();
+    return Result<ListSessionsResponse>::Ok(std::move(result));
+}
+
+auto DatabaseSessionStore::UpdateSession(const UpdateSessionRequest& request)
+    -> Result<Session> {
+    // First get the existing session
+    auto get_result = GetSession(request.session_id, request.user_id, true);
+    if (!get_result.success) {
+        return get_result;
+    }
+
+    auto session = std::move(get_result.value);
+
+    // Apply updates
+    if (request.title.has_value()) {
+        session.title = *request.title;
+    }
+    if (request.model_id.has_value()) {
+        session.model_id = *request.model_id;
+    }
+    if (request.system_prompt.has_value()) {
+        session.system_prompt = *request.system_prompt;
+    }
+    session.updated_at = Now();
+
+    // Save
+    grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest db_request;
+    db_request.set_collection(config_.collection_name);
+    db_request.set_id(session.id);
+    db_request.set_merge(false);  // Replace entire document
+    *db_request.mutable_data() = SessionToDocument(session);
+
+    ::smartbotic::database::Document response;
+    auto status = document_stub_->UpdateDocument(&ctx, db_request, &response);
+
+    if (!status.ok()) {
+        return Result<Session>::Error("Failed to update session: " + status.error_message());
+    }
+
+    return Result<Session>::Ok(std::move(session));
+}
+
+auto DatabaseSessionStore::DeleteSession(const std::string& session_id,
+                                          const std::string& user_id) -> Result<bool> {
+    // Verify ownership first
+    auto get_result = GetSession(session_id, user_id, false);
+    if (!get_result.success) {
+        return Result<bool>::Error(get_result.error);
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::DeleteDocumentRequest request;
+    request.set_collection(config_.collection_name);
+    request.set_id(session_id);
+
+    ::smartbotic::database::DeleteDocumentResponse response;
+    auto status = document_stub_->DeleteDocument(&ctx, request, &response);
+
+    if (!status.ok()) {
+        return Result<bool>::Error("Failed to delete session: " + status.error_message());
+    }
+
+    spdlog::debug("Deleted session {}", session_id);
+    return Result<bool>::Ok(true);
+}
+
+auto DatabaseSessionStore::AddMessage(const std::string& session_id,
+                                       const std::string& user_id,
+                                       const Message& message) -> Result<Session> {
+    auto get_result = GetSession(session_id, user_id, true);
+    if (!get_result.success) {
+        return get_result;
+    }
+
+    auto session = std::move(get_result.value);
+
+    // Add message with ID if not present
+    Message new_message = message;
+    if (new_message.id.empty()) {
+        new_message.id = GenerateId();
+    }
+    if (new_message.created_at == std::chrono::system_clock::time_point{}) {
+        new_message.created_at = Now();
+    }
+
+    session.messages.push_back(new_message);
+    session.updated_at = Now();
+
+    // Auto-generate title from first user message if still default
+    if (session.title == "New Chat" && new_message.role == MessageRole::kUser) {
+        for (const auto& part : new_message.content) {
+            if (part.type == ContentPartType::kText && !part.text.empty()) {
+                // Use first 50 chars of first user message as title
+                session.title = part.text.substr(0, 50);
+                if (part.text.length() > 50) {
+                    session.title += "...";
+                }
+                break;
+            }
+        }
+    }
+
+    // Save
+    grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest db_request;
+    db_request.set_collection(config_.collection_name);
+    db_request.set_id(session.id);
+    db_request.set_merge(false);
+    *db_request.mutable_data() = SessionToDocument(session);
+
+    ::smartbotic::database::Document response;
+    auto status = document_stub_->UpdateDocument(&ctx, db_request, &response);
+
+    if (!status.ok()) {
+        return Result<Session>::Error("Failed to add message: " + status.error_message());
+    }
+
+    return Result<Session>::Ok(std::move(session));
+}
+
+auto DatabaseSessionStore::ClearMessages(const std::string& session_id,
+                                          const std::string& user_id) -> Result<Session> {
+    auto get_result = GetSession(session_id, user_id, false);
+    if (!get_result.success) {
+        return get_result;
+    }
+
+    auto session = std::move(get_result.value);
+    session.messages.clear();
+    session.updated_at = Now();
+
+    // Save
+    grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest db_request;
+    db_request.set_collection(config_.collection_name);
+    db_request.set_id(session.id);
+    db_request.set_merge(false);
+    *db_request.mutable_data() = SessionToDocument(session);
+
+    ::smartbotic::database::Document response;
+    auto status = document_stub_->UpdateDocument(&ctx, db_request, &response);
+
+    if (!status.ok()) {
+        return Result<Session>::Error("Failed to clear messages: " + status.error_message());
+    }
+
+    spdlog::debug("Cleared messages for session {}", session_id);
+    return Result<Session>::Ok(std::move(session));
+}
+
+}  // namespace smartbotic::llm::session

+ 102 - 0
llm/src/session/session.cpp

@@ -0,0 +1,102 @@
+#include "smartbotic/llm/session/session.hpp"
+
+#include <chrono>
+#include <iomanip>
+#include <random>
+#include <sstream>
+
+namespace smartbotic::llm::session {
+
+auto MessageRoleToString(MessageRole role) -> std::string {
+    switch (role) {
+        case MessageRole::kSystem:
+            return "system";
+        case MessageRole::kUser:
+            return "user";
+        case MessageRole::kAssistant:
+            return "assistant";
+        case MessageRole::kTool:
+            return "tool";
+    }
+    return "user";
+}
+
+auto ParseMessageRole(const std::string& str) -> std::optional<MessageRole> {
+    if (str == "system") return MessageRole::kSystem;
+    if (str == "user") return MessageRole::kUser;
+    if (str == "assistant") return MessageRole::kAssistant;
+    if (str == "tool") return MessageRole::kTool;
+    return std::nullopt;
+}
+
+auto GenerateId() -> std::string {
+    static std::random_device rd;
+    static std::mt19937_64 gen(rd());
+    static std::uniform_int_distribution<uint64_t> dis;
+
+    // Generate a UUID-like string
+    uint64_t a = dis(gen);
+    uint64_t b = dis(gen);
+
+    std::ostringstream oss;
+    oss << std::hex << std::setfill('0');
+    oss << std::setw(8) << ((a >> 32) & 0xFFFFFFFF);
+    oss << '-';
+    oss << std::setw(4) << ((a >> 16) & 0xFFFF);
+    oss << '-';
+    oss << std::setw(4) << (a & 0xFFFF);
+    oss << '-';
+    oss << std::setw(4) << ((b >> 48) & 0xFFFF);
+    oss << '-';
+    oss << std::setw(12) << (b & 0xFFFFFFFFFFFF);
+
+    return oss.str();
+}
+
+auto Now() -> std::chrono::system_clock::time_point {
+    return std::chrono::system_clock::now();
+}
+
+auto TimestampToString(std::chrono::system_clock::time_point tp) -> std::string {
+    auto time_t = std::chrono::system_clock::to_time_t(tp);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+                  tp.time_since_epoch())
+                  .count() %
+              1000;
+
+    std::tm tm{};
+    gmtime_r(&time_t, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << '.' << std::setfill('0') << std::setw(3) << ms << 'Z';
+
+    return oss.str();
+}
+
+auto ParseTimestamp(const std::string& str)
+    -> std::optional<std::chrono::system_clock::time_point> {
+    std::tm tm{};
+    std::istringstream iss(str);
+
+    // Try parsing with milliseconds
+    iss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    if (iss.fail()) {
+        return std::nullopt;
+    }
+
+    // Parse milliseconds if present
+    int ms = 0;
+    if (iss.peek() == '.') {
+        iss.ignore();  // Skip '.'
+        iss >> ms;
+    }
+
+    auto time_t = timegm(&tm);
+    auto tp = std::chrono::system_clock::from_time_t(time_t);
+    tp += std::chrono::milliseconds(ms);
+
+    return tp;
+}
+
+}  // namespace smartbotic::llm::session

+ 103 - 0
llm/src/tool/tool_registry.cpp

@@ -0,0 +1,103 @@
+#include "smartbotic/llm/tool/tool_registry.hpp"
+
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::llm::tool {
+
+void ToolRegistry::RegisterTool(const ToolDefinition& definition) {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    RegisteredTool tool;
+    tool.definition = definition;
+    tools_[definition.name] = std::move(tool);
+
+    spdlog::debug("Registered tool: {}", definition.name);
+}
+
+void ToolRegistry::RegisterTool(const ToolDefinition& definition, ToolExecutor executor) {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    RegisteredTool tool;
+    tool.definition = definition;
+    tool.executor = std::move(executor);
+    tools_[definition.name] = std::move(tool);
+
+    spdlog::debug("Registered tool with executor: {}", definition.name);
+}
+
+void ToolRegistry::UnregisterTool(const std::string& name) {
+    std::lock_guard<std::mutex> lock(mutex_);
+    tools_.erase(name);
+    spdlog::debug("Unregistered tool: {}", name);
+}
+
+auto ToolRegistry::GetToolsForWorkspace(const std::string& workspace_id)
+    -> std::vector<ToolDefinition> {
+    std::lock_guard<std::mutex> lock(mutex_);
+    std::vector<ToolDefinition> result;
+
+    for (const auto& [name, tool] : tools_) {
+        // Include global tools (no workspace_id) and workspace-specific tools
+        if (!tool.definition.workspace_id.has_value() ||
+            tool.definition.workspace_id.value() == workspace_id) {
+            result.push_back(tool.definition);
+        }
+    }
+
+    return result;
+}
+
+auto ToolRegistry::GetAllTools() -> std::vector<ToolDefinition> {
+    std::lock_guard<std::mutex> lock(mutex_);
+    std::vector<ToolDefinition> result;
+    result.reserve(tools_.size());
+
+    for (const auto& [name, tool] : tools_) {
+        result.push_back(tool.definition);
+    }
+
+    return result;
+}
+
+auto ToolRegistry::HasExecutor(const std::string& name) const -> bool {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    auto it = tools_.find(name);
+    if (it == tools_.end()) {
+        return false;
+    }
+
+    return it->second.executor.has_value();
+}
+
+auto ToolRegistry::Execute(const std::string& name,
+                           const std::string& arguments,
+                           const ToolExecutionContext& context) -> std::optional<ToolExecutionResult> {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    auto it = tools_.find(name);
+    if (it == tools_.end()) {
+        spdlog::warn("Tool not found: {}", name);
+        return std::nullopt;
+    }
+
+    if (!it->second.executor.has_value()) {
+        spdlog::warn("Tool has no executor: {}", name);
+        return std::nullopt;
+    }
+
+    try {
+        spdlog::debug("Executing tool: {}", name);
+        return it->second.executor.value()(arguments, context);
+    } catch (const std::exception& e) {
+        spdlog::error("Tool execution failed: {} - {}", name, e.what());
+        return ToolExecutionResult{false, std::string("Execution error: ") + e.what(), true};
+    }
+}
+
+void ToolRegistry::Clear() {
+    std::lock_guard<std::mutex> lock(mutex_);
+    tools_.clear();
+}
+
+}  // namespace smartbotic::llm::tool

+ 1 - 0
proto/CMakeLists.txt

@@ -12,6 +12,7 @@ file(MAKE_DIRECTORY ${PROTO_GEN_DIR})
 set(PROTO_FILES
     ${PROTO_DIR}/crm.proto
     ${PROTO_DIR}/database.proto
+    ${PROTO_DIR}/llm.proto
 )
 
 # Generate C++ sources from proto files

+ 459 - 0
proto/proto/llm.proto

@@ -0,0 +1,459 @@
+syntax = "proto3";
+
+package smartbotic.llm;
+
+option cc_enable_arenas = true;
+
+// ============================================================================
+// Common Types
+// ============================================================================
+
+message Empty {}
+
+message Timestamp {
+    int64 seconds = 1;
+    int32 nanos = 2;
+}
+
+// ============================================================================
+// Provider Types
+// ============================================================================
+
+enum ProviderType {
+    PROVIDER_TYPE_UNSPECIFIED = 0;
+    PROVIDER_TYPE_OPENAI = 1;
+    PROVIDER_TYPE_ANTHROPIC = 2;
+}
+
+message ProviderInfo {
+    string id = 1;
+    string name = 2;
+    ProviderType type = 3;
+    string base_url = 4;
+    bool enabled = 5;
+    bool healthy = 6;
+    Timestamp created_at = 7;
+    Timestamp updated_at = 8;
+}
+
+message ModelInfo {
+    string id = 1;
+    string name = 2;
+    string provider_id = 3;
+    ProviderType provider_type = 4;
+    int64 context_length = 5;
+    bool supports_tools = 6;
+    bool supports_vision = 7;
+}
+
+// ============================================================================
+// Message Types
+// ============================================================================
+
+enum MessageRole {
+    MESSAGE_ROLE_UNSPECIFIED = 0;
+    MESSAGE_ROLE_SYSTEM = 1;
+    MESSAGE_ROLE_USER = 2;
+    MESSAGE_ROLE_ASSISTANT = 3;
+    MESSAGE_ROLE_TOOL = 4;
+}
+
+message ContentPart {
+    oneof part {
+        string text = 1;
+        ImageContent image = 2;
+        ToolUseContent tool_use = 3;
+        ToolResultContent tool_result = 4;
+    }
+}
+
+message ImageContent {
+    string url = 1;           // URL or base64 data URI
+    string media_type = 2;    // e.g., "image/png", "image/jpeg"
+}
+
+message ToolUseContent {
+    string id = 1;
+    string name = 2;
+    string arguments = 3;     // JSON string
+}
+
+message ToolResultContent {
+    string tool_use_id = 1;
+    string content = 2;
+    bool is_error = 3;
+}
+
+message Message {
+    string id = 1;
+    MessageRole role = 2;
+    repeated ContentPart content = 3;
+    Timestamp created_at = 4;
+    PageContext page_context = 5;    // Optional page context when message was sent
+}
+
+message PageContext {
+    string path = 1;              // e.g., "/views/customers"
+    string title = 2;             // e.g., "Customers View"
+    string workspace_id = 3;
+    string collection = 4;
+    string view_id = 5;
+}
+
+// ============================================================================
+// Tool Types
+// ============================================================================
+
+message ToolDefinition {
+    string name = 1;
+    string description = 2;
+    string input_schema = 3;     // JSON Schema as string
+}
+
+message ToolCall {
+    string id = 1;
+    string name = 2;
+    string arguments = 3;        // JSON string
+}
+
+message ToolResult {
+    string tool_call_id = 1;
+    string content = 2;
+    bool is_error = 3;
+}
+
+// ============================================================================
+// Session Types
+// ============================================================================
+
+message Session {
+    string id = 1;
+    string user_id = 2;
+    string workspace_id = 3;
+    string title = 4;
+    string model_id = 5;
+    string system_prompt = 6;
+    repeated Message messages = 7;
+    Timestamp created_at = 8;
+    Timestamp updated_at = 9;
+}
+
+message SessionSummary {
+    string id = 1;
+    string user_id = 2;
+    string workspace_id = 3;
+    string title = 4;
+    string model_id = 5;
+    int32 message_count = 6;
+    Timestamp created_at = 7;
+    Timestamp updated_at = 8;
+}
+
+// ============================================================================
+// LLMService - Chat Completion
+// ============================================================================
+
+message ChatRequest {
+    string session_id = 1;
+    string user_id = 2;
+    string workspace_id = 3;
+    repeated ContentPart content = 4;     // User message content
+    PageContext page_context = 5;
+
+    // Optional overrides
+    string model_id = 6;                  // Override session model
+    string provider_id = 7;               // Use specific provider
+    float temperature = 8;
+    int32 max_tokens = 9;
+    repeated ToolDefinition tools = 10;   // Tools available for this request
+}
+
+message ChatResponse {
+    string session_id = 1;
+    Message user_message = 2;             // The stored user message
+    Message assistant_message = 3;        // The assistant's response
+    repeated ToolCall tool_calls = 4;     // If assistant wants to use tools
+    UsageInfo usage = 5;
+    FinishReason finish_reason = 6;
+}
+
+message ChatStreamChunk {
+    string session_id = 1;
+    oneof chunk {
+        string content_delta = 2;         // Text content being generated
+        ToolCall tool_call = 3;           // Complete tool call when ready
+        StreamMetadata metadata = 4;      // Final metadata after stream ends
+    }
+}
+
+message StreamMetadata {
+    Message user_message = 1;
+    Message assistant_message = 2;
+    UsageInfo usage = 3;
+    FinishReason finish_reason = 4;
+}
+
+message UsageInfo {
+    int32 prompt_tokens = 1;
+    int32 completion_tokens = 2;
+    int32 total_tokens = 3;
+}
+
+enum FinishReason {
+    FINISH_REASON_UNSPECIFIED = 0;
+    FINISH_REASON_STOP = 1;
+    FINISH_REASON_LENGTH = 2;
+    FINISH_REASON_TOOL_USE = 3;
+    FINISH_REASON_CONTENT_FILTER = 4;
+    FINISH_REASON_ERROR = 5;
+}
+
+message SubmitToolResultsRequest {
+    string session_id = 1;
+    string user_id = 2;
+    string workspace_id = 3;
+    repeated ToolResult tool_results = 4;
+
+    // Optional overrides (same as ChatRequest)
+    string model_id = 5;
+    string provider_id = 6;
+    float temperature = 7;
+    int32 max_tokens = 8;
+    repeated ToolDefinition tools = 9;
+}
+
+service LLMService {
+    // Send a message and get a complete response
+    rpc Chat(ChatRequest) returns (ChatResponse);
+
+    // Send a message and stream the response
+    rpc ChatStream(ChatRequest) returns (stream ChatStreamChunk);
+
+    // Submit tool results and continue the conversation
+    rpc SubmitToolResults(SubmitToolResultsRequest) returns (ChatResponse);
+
+    // Submit tool results and stream the continued response
+    rpc SubmitToolResultsStream(SubmitToolResultsRequest) returns (stream ChatStreamChunk);
+}
+
+// ============================================================================
+// SessionService - Session CRUD and Message Management
+// ============================================================================
+
+message CreateSessionRequest {
+    string user_id = 1;
+    string workspace_id = 2;
+    string title = 3;                     // Optional, auto-generated if empty
+    string model_id = 4;                  // Optional, uses default if empty
+    string system_prompt = 5;             // Optional custom system prompt
+}
+
+message GetSessionRequest {
+    string session_id = 1;
+    string user_id = 2;
+    bool include_messages = 3;            // If true, include all messages
+}
+
+message ListSessionsRequest {
+    string user_id = 1;
+    string workspace_id = 2;              // Optional filter by workspace
+    int32 page_size = 3;
+    string page_token = 4;
+}
+
+message ListSessionsResponse {
+    repeated SessionSummary sessions = 1;
+    string next_page_token = 2;
+    int32 total_count = 3;
+}
+
+message UpdateSessionRequest {
+    string session_id = 1;
+    string user_id = 2;
+    string title = 3;                     // New title
+    string model_id = 4;                  // New model
+    string system_prompt = 5;             // New system prompt
+}
+
+message DeleteSessionRequest {
+    string session_id = 1;
+    string user_id = 2;
+}
+
+message DeleteSessionResponse {
+    bool deleted = 1;
+}
+
+message ClearSessionMessagesRequest {
+    string session_id = 1;
+    string user_id = 2;
+}
+
+message AddMessageRequest {
+    string session_id = 1;
+    string user_id = 2;
+    Message message = 3;
+}
+
+service SessionService {
+    // Create a new chat session
+    rpc CreateSession(CreateSessionRequest) returns (Session);
+
+    // Get a session by ID
+    rpc GetSession(GetSessionRequest) returns (Session);
+
+    // List sessions for a user
+    rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse);
+
+    // Update session metadata
+    rpc UpdateSession(UpdateSessionRequest) returns (Session);
+
+    // Delete a session
+    rpc DeleteSession(DeleteSessionRequest) returns (DeleteSessionResponse);
+
+    // Clear all messages in a session
+    rpc ClearSessionMessages(ClearSessionMessagesRequest) returns (Session);
+
+    // Add a message to a session (for tool results, etc.)
+    rpc AddMessage(AddMessageRequest) returns (Session);
+}
+
+// ============================================================================
+// ModelService - Provider and Model Discovery
+// ============================================================================
+
+message ListProvidersRequest {
+    bool include_disabled = 1;
+}
+
+message ListProvidersResponse {
+    repeated ProviderInfo providers = 1;
+}
+
+message ListModelsRequest {
+    string provider_id = 1;               // Optional filter by provider
+    ProviderType provider_type = 2;       // Optional filter by type
+}
+
+message ListModelsResponse {
+    repeated ModelInfo models = 1;
+}
+
+message RefreshModelsRequest {
+    string provider_id = 1;               // Optional, refresh all if empty
+}
+
+message RefreshModelsResponse {
+    int32 models_found = 1;
+    repeated string errors = 2;           // Any errors during refresh
+}
+
+message GetProviderHealthRequest {
+    string provider_id = 1;
+}
+
+message GetProviderHealthResponse {
+    string provider_id = 1;
+    bool healthy = 2;
+    string error = 3;                     // Error message if unhealthy
+    int64 latency_ms = 4;                 // Health check latency
+    Timestamp checked_at = 5;
+}
+
+service ModelService {
+    // List all configured providers
+    rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse);
+
+    // List available models
+    rpc ListModels(ListModelsRequest) returns (ListModelsResponse);
+
+    // Refresh model list from providers
+    rpc RefreshModels(RefreshModelsRequest) returns (RefreshModelsResponse);
+
+    // Check provider health
+    rpc GetProviderHealth(GetProviderHealthRequest) returns (GetProviderHealthResponse);
+}
+
+// ============================================================================
+// ConfigService - Provider Configuration and BYOK
+// ============================================================================
+
+message ProviderConfig {
+    string id = 1;
+    string name = 2;
+    ProviderType type = 3;
+    string base_url = 4;
+    bool enabled = 5;
+    bool has_api_key = 6;                 // True if API key is set (never expose actual key)
+    Timestamp created_at = 7;
+    Timestamp updated_at = 8;
+}
+
+message GetProviderConfigRequest {
+    string provider_id = 1;
+}
+
+message SetProviderConfigRequest {
+    string id = 1;                        // Empty for new provider
+    string name = 2;
+    ProviderType type = 3;
+    string base_url = 4;
+    string api_key = 5;                   // Set/update API key (empty = don't change)
+    bool enabled = 6;
+}
+
+message DeleteProviderConfigRequest {
+    string provider_id = 1;
+}
+
+message DeleteProviderConfigResponse {
+    bool deleted = 1;
+}
+
+message ListProviderConfigsRequest {
+    bool include_disabled = 1;
+}
+
+message ListProviderConfigsResponse {
+    repeated ProviderConfig providers = 1;
+}
+
+message SetWorkspaceApiKeyRequest {
+    string workspace_id = 1;
+    string provider_id = 2;
+    string api_key = 3;
+}
+
+message GetWorkspaceApiKeyRequest {
+    string workspace_id = 1;
+    string provider_id = 2;
+}
+
+message WorkspaceApiKeyResponse {
+    string workspace_id = 1;
+    string provider_id = 2;
+    bool has_api_key = 3;                 // True if workspace has BYOK set
+    Timestamp set_at = 4;
+}
+
+message DeleteWorkspaceApiKeyRequest {
+    string workspace_id = 1;
+    string provider_id = 2;
+}
+
+message DeleteWorkspaceApiKeyResponse {
+    bool deleted = 1;
+}
+
+service ConfigService {
+    // Provider configuration (admin)
+    rpc GetProviderConfig(GetProviderConfigRequest) returns (ProviderConfig);
+    rpc SetProviderConfig(SetProviderConfigRequest) returns (ProviderConfig);
+    rpc DeleteProviderConfig(DeleteProviderConfigRequest) returns (DeleteProviderConfigResponse);
+    rpc ListProviderConfigs(ListProviderConfigsRequest) returns (ListProviderConfigsResponse);
+
+    // Workspace BYOK (Bring Your Own Key)
+    rpc SetWorkspaceApiKey(SetWorkspaceApiKeyRequest) returns (Empty);
+    rpc GetWorkspaceApiKey(GetWorkspaceApiKeyRequest) returns (WorkspaceApiKeyResponse);
+    rpc DeleteWorkspaceApiKey(DeleteWorkspaceApiKeyRequest) returns (DeleteWorkspaceApiKeyResponse);
+}

+ 33 - 0
systemd/smartbotic-llm.service

@@ -0,0 +1,33 @@
+[Unit]
+Description=SmartBotic CRM LLM Service
+Documentation=https://github.com/smartbotic/smartbotic-crm
+After=network.target smartbotic-database.service
+Requires=smartbotic-database.service
+
+[Service]
+Type=simple
+User=%i
+Group=%i
+WorkingDirectory=/opt/smartbotic-crm
+ExecStart=/opt/smartbotic-crm/build/llm/smartbotic-crm-llm
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+# Shutdown timeout - send SIGKILL after 10 seconds if graceful shutdown fails
+TimeoutStopSec=10
+
+# Security hardening
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+
+# Environment
+Environment="SMARTBOTIC_LLM_ADDRESS=0.0.0.0"
+Environment="SMARTBOTIC_LLM_PORT=50052"
+Environment="SMARTBOTIC_LLM_DATABASE_ADDRESS=localhost:50151"
+Environment="SMARTBOTIC_LLM_LOG_LEVEL=info"
+
+[Install]
+WantedBy=multi-user.target

+ 3 - 1
systemd/smartbotic-webserver.service

@@ -1,8 +1,9 @@
 [Unit]
 Description=SmartBotic CRM Webserver
 Documentation=https://github.com/smartbotic/smartbotic-crm
-After=network.target smartbotic-database.service
+After=network.target smartbotic-database.service smartbotic-llm.service
 Requires=smartbotic-database.service
+Wants=smartbotic-llm.service
 
 [Service]
 Type=simple
@@ -28,6 +29,7 @@ Environment="SMARTBOTIC_SERVER_HTTP_PORT=18080"
 Environment="SMARTBOTIC_SERVER_WS_PORT=18081"
 Environment="SMARTBOTIC_SERVER_DATABASE_HOST=localhost"
 Environment="SMARTBOTIC_SERVER_DATABASE_PORT=50151"
+Environment="SMARTBOTIC_SERVER_LLM_ADDRESS=localhost:50052"
 Environment="SMARTBOTIC_SERVER_STATIC_PATH=/opt/smartbotic-crm/webui/dist"
 # Set JWT secret via environment or config file
 # Environment="SMARTBOTIC_SERVER_JWT_SECRET=your-secret-here"

+ 1 - 0
webserver/CMakeLists.txt

@@ -16,6 +16,7 @@ add_library(smartbotic_webserver STATIC
     src/config.cpp
     src/http_server.cpp
     src/database_client.cpp
+    src/llm_client.cpp
     src/auth_service.cpp
     src/user_service.cpp
     src/workspace_service.cpp

+ 3 - 0
webserver/include/smartbotic/webserver/config.hpp

@@ -71,6 +71,9 @@ struct HttpServerConfig {
     // Database gRPC connection
     std::string database_address = "127.0.0.1:50151";  // Database service address
 
+    // LLM gRPC connection
+    std::string llm_address = "127.0.0.1:50052";  // LLM service address
+
     /// Get the full address string (address:port)
     [[nodiscard]] auto GetListenAddress() const -> std::string { return address + ":" + std::to_string(port); }
 

+ 32 - 0
webserver/include/smartbotic/webserver/http_server.hpp

@@ -27,6 +27,7 @@
 #include "smartbotic/webserver/view_service.hpp"
 #include "smartbotic/webserver/workspace_service.hpp"
 #include "smartbotic/webserver/ws_handler.hpp"
+#include "smartbotic/webserver/llm_client.hpp"
 
 namespace smartbotic::webserver {
 
@@ -187,6 +188,9 @@ public:
     /// Get the page service
     [[nodiscard]] auto GetPageService() -> PageService& { return *pageService_; }
 
+    /// Get the LLM client
+    [[nodiscard]] auto GetLlmClient() -> LlmClient& { return *llmClient_; }
+
     /// Get the authorization service
     [[nodiscard]] auto GetAuthorizationService() -> AuthorizationService& { return *authorizationService_; }
 
@@ -212,6 +216,7 @@ private:
     void SetupDocumentRoutes();
     void SetupViewRoutes();
     void SetupPageRoutes();
+    void SetupLlmRoutes();
     void RunHttpServer();
     [[nodiscard]] auto ConnectToDatabase() -> bool;
     [[nodiscard]] auto InitializeServices() -> bool;
@@ -291,6 +296,32 @@ private:
     void HandleUpdatePageSharing(const httplib::Request& req, httplib::Response& res);
     void HandleDeletePage(const httplib::Request& req, httplib::Response& res);
 
+    // LLM route handlers - Sessions
+    void HandleLlmCreateSession(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmListSessions(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmGetSession(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmDeleteSession(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmSendMessage(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmStreamMessage(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmClearMessages(const httplib::Request& req, httplib::Response& res);
+
+    // LLM route handlers - Models & Providers
+    void HandleLlmListProviders(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmListModels(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmRefreshModels(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmGetProviderHealth(const httplib::Request& req, httplib::Response& res);
+
+    // LLM route handlers - Admin (Provider Config)
+    void HandleLlmAdminListProviders(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmAdminCreateProvider(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmAdminUpdateProvider(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmAdminDeleteProvider(const httplib::Request& req, httplib::Response& res);
+
+    // LLM route handlers - Workspace BYOK
+    void HandleLlmSetWorkspaceKey(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmGetWorkspaceKey(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmDeleteWorkspaceKey(const httplib::Request& req, httplib::Response& res);
+
     // Authentication middleware helper
     [[nodiscard]] auto AuthenticateRequest(const httplib::Request& req) -> std::optional<AuthUser>;
 
@@ -321,6 +352,7 @@ private:
     std::unique_ptr<PageService> pageService_;
     std::unique_ptr<AuthorizationService> authorizationService_;
     std::unique_ptr<WsHandler> wsHandler_;
+    std::unique_ptr<LlmClient> llmClient_;
 
     std::thread httpThread_;
 

+ 175 - 0
webserver/include/smartbotic/webserver/llm_client.hpp

@@ -0,0 +1,175 @@
+#pragma once
+
+#include <grpcpp/grpcpp.h>
+
+#include <atomic>
+#include <chrono>
+#include <functional>
+#include <memory>
+#include <mutex>
+#include <string>
+
+#include "llm.grpc.pb.h"
+
+namespace smartbotic::webserver {
+
+/// Configuration for LLM client connection
+struct LlmClientConfig {
+    std::string address = "127.0.0.1:50052";
+
+    // Connection settings
+    std::chrono::milliseconds connect_timeout{5000};
+    std::chrono::milliseconds request_timeout{120000};  // Longer for LLM responses
+    std::chrono::milliseconds stream_timeout{300000};   // Even longer for streaming
+
+    // Retry settings
+    uint32_t max_retries = 2;
+    std::chrono::milliseconds initial_backoff{500};
+    std::chrono::milliseconds max_backoff{5000};
+    double backoff_multiplier = 2.0;
+};
+
+/// Stream chunk callback for streaming responses
+using StreamChunkCallback = std::function<bool(const ::smartbotic::llm::ChatStreamChunk&)>;
+
+/// gRPC client for connecting to the LLM service
+class LlmClient {
+public:
+    explicit LlmClient(LlmClientConfig config = LlmClientConfig{});
+    ~LlmClient();
+
+    // Non-copyable and non-movable
+    LlmClient(const LlmClient&) = delete;
+    auto operator=(const LlmClient&) -> LlmClient& = delete;
+    LlmClient(LlmClient&&) = delete;
+    auto operator=(LlmClient&&) -> LlmClient& = delete;
+
+    /// Connect to the LLM server
+    [[nodiscard]] auto Connect() -> bool;
+
+    /// Disconnect from the LLM server
+    void Disconnect();
+
+    /// Check if the client is connected
+    [[nodiscard]] auto IsConnected() const -> bool;
+
+    /// Get the configuration
+    [[nodiscard]] auto GetConfig() const -> const LlmClientConfig& { return config_; }
+
+    // =========================================================================
+    // LLM Service Operations
+    // =========================================================================
+
+    /// Send a chat message and get a response
+    [[nodiscard]] auto Chat(const ::smartbotic::llm::ChatRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::ChatResponse>;
+
+    /// Send a chat message with streaming response
+    [[nodiscard]] auto ChatStream(const ::smartbotic::llm::ChatRequest& request,
+                                  StreamChunkCallback callback) -> grpc::Status;
+
+    /// Submit tool results
+    [[nodiscard]] auto SubmitToolResults(const ::smartbotic::llm::SubmitToolResultsRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::ChatResponse>;
+
+    // =========================================================================
+    // Session Service Operations
+    // =========================================================================
+
+    /// Create a new session
+    [[nodiscard]] auto CreateSession(const ::smartbotic::llm::CreateSessionRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::Session>;
+
+    /// Get a session by ID
+    [[nodiscard]] auto GetSession(const ::smartbotic::llm::GetSessionRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::Session>;
+
+    /// List sessions for a user
+    [[nodiscard]] auto ListSessions(const ::smartbotic::llm::ListSessionsRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::ListSessionsResponse>;
+
+    /// Update a session
+    [[nodiscard]] auto UpdateSession(const ::smartbotic::llm::UpdateSessionRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::Session>;
+
+    /// Delete a session
+    [[nodiscard]] auto DeleteSession(const ::smartbotic::llm::DeleteSessionRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::DeleteSessionResponse>;
+
+    /// Clear messages in a session
+    [[nodiscard]] auto ClearSessionMessages(const ::smartbotic::llm::ClearSessionMessagesRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::Session>;
+
+    // =========================================================================
+    // Model Service Operations
+    // =========================================================================
+
+    /// List providers
+    [[nodiscard]] auto ListProviders(const ::smartbotic::llm::ListProvidersRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::ListProvidersResponse>;
+
+    /// List models
+    [[nodiscard]] auto ListModels(const ::smartbotic::llm::ListModelsRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::ListModelsResponse>;
+
+    /// Refresh models
+    [[nodiscard]] auto RefreshModels(const ::smartbotic::llm::RefreshModelsRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::RefreshModelsResponse>;
+
+    /// Get provider health
+    [[nodiscard]] auto GetProviderHealth(const ::smartbotic::llm::GetProviderHealthRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::GetProviderHealthResponse>;
+
+    // =========================================================================
+    // Config Service Operations
+    // =========================================================================
+
+    /// Get provider config
+    [[nodiscard]] auto GetProviderConfig(const ::smartbotic::llm::GetProviderConfigRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::ProviderConfig>;
+
+    /// Set provider config
+    [[nodiscard]] auto SetProviderConfig(const ::smartbotic::llm::SetProviderConfigRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::ProviderConfig>;
+
+    /// Delete provider config
+    [[nodiscard]] auto DeleteProviderConfig(const ::smartbotic::llm::DeleteProviderConfigRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::DeleteProviderConfigResponse>;
+
+    /// List provider configs
+    [[nodiscard]] auto ListProviderConfigs(const ::smartbotic::llm::ListProviderConfigsRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::ListProviderConfigsResponse>;
+
+    /// Set workspace API key
+    [[nodiscard]] auto SetWorkspaceApiKey(const ::smartbotic::llm::SetWorkspaceApiKeyRequest& request)
+        -> grpc::Status;
+
+    /// Get workspace API key
+    [[nodiscard]] auto GetWorkspaceApiKey(const ::smartbotic::llm::GetWorkspaceApiKeyRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::WorkspaceApiKeyResponse>;
+
+    /// Delete workspace API key
+    [[nodiscard]] auto DeleteWorkspaceApiKey(const ::smartbotic::llm::DeleteWorkspaceApiKeyRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::DeleteWorkspaceApiKeyResponse>;
+
+private:
+    /// Create the gRPC channel
+    void CreateChannel();
+
+    /// Create service stubs
+    void CreateStubs();
+
+    LlmClientConfig config_;
+    std::shared_ptr<grpc::Channel> channel_;
+
+    // Service stubs
+    std::unique_ptr<::smartbotic::llm::LLMService::Stub> llm_stub_;
+    std::unique_ptr<::smartbotic::llm::SessionService::Stub> session_stub_;
+    std::unique_ptr<::smartbotic::llm::ModelService::Stub> model_stub_;
+    std::unique_ptr<::smartbotic::llm::ConfigService::Stub> config_stub_;
+
+    std::atomic<bool> connected_{false};
+    mutable std::mutex mutex_;
+};
+
+}  // namespace smartbotic::webserver

+ 13 - 0
webserver/include/smartbotic/webserver/permissions.hpp

@@ -68,6 +68,15 @@ constexpr std::string_view kPagesCreate = "system:pages:create";
 constexpr std::string_view kPagesUpdate = "system:pages:update";
 constexpr std::string_view kPagesDelete = "system:pages:delete";
 
+// LLM Provider management (system-level)
+constexpr std::string_view kLlmProvidersRead = "system:llm_providers:read";
+constexpr std::string_view kLlmProvidersCreate = "system:llm_providers:create";
+constexpr std::string_view kLlmProvidersUpdate = "system:llm_providers:update";
+constexpr std::string_view kLlmProvidersDelete = "system:llm_providers:delete";
+
+// LLM Chat access (system-level)
+constexpr std::string_view kLlmChat = "system:llm:chat";
+
 // ============================================================================
 // WORKSPACE SCOPE - Per-workspace operations
 // Format: workspace:{workspace_id}:{action}
@@ -91,6 +100,10 @@ constexpr std::string_view kWorkspaceManageViews = "workspace:*:manage_views";
 // Workspace page management
 constexpr std::string_view kWorkspaceManagePages = "workspace:*:manage_pages";
 
+// Workspace LLM operations
+constexpr std::string_view kLlmAssignProvider = "workspace:*:llm_assign_provider";
+constexpr std::string_view kLlmByok = "workspace:*:llm_byok";
+
 // ============================================================================
 // PAGE SCOPE - Per-workspace page operations (ownership-based)
 // Format: page:{workspace_id}:{action}

+ 1408 - 0
webserver/src/http_server.cpp

@@ -647,6 +647,9 @@ void HttpServer::SetupRoutes() {
     // Setup page routes (page builder)
     SetupPageRoutes();
 
+    // Setup LLM routes (AI assistant)
+    SetupLlmRoutes();
+
     // Setup collection routes (before workspace routes since they're more specific)
     SetupCollectionRoutes();
 
@@ -5566,4 +5569,1409 @@ void HttpServer::HandleDeletePage(const httplib::Request& req, httplib::Response
     }
 }
 
+// ============================================================================
+// LLM Routes
+// ============================================================================
+
+void HttpServer::SetupLlmRoutes() {
+    // Session management
+    httpServer_->Post("/api/llm/sessions",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmCreateSession(req, res);
+        });
+
+    httpServer_->Get("/api/llm/sessions",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmListSessions(req, res);
+        });
+
+    httpServer_->Get(R"(/api/llm/sessions/([^/]+))",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmGetSession(req, res);
+        });
+
+    httpServer_->Delete(R"(/api/llm/sessions/([^/]+))",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmDeleteSession(req, res);
+        });
+
+    httpServer_->Post(R"(/api/llm/sessions/([^/]+)/messages)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmSendMessage(req, res);
+        });
+
+    httpServer_->Post(R"(/api/llm/sessions/([^/]+)/stream)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmStreamMessage(req, res);
+        });
+
+    httpServer_->Post(R"(/api/llm/sessions/([^/]+)/clear)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmClearMessages(req, res);
+        });
+
+    // Models and providers (read-only)
+    httpServer_->Get("/api/llm/providers",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmListProviders(req, res);
+        });
+
+    httpServer_->Get("/api/llm/models",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmListModels(req, res);
+        });
+
+    httpServer_->Post("/api/llm/models/refresh",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmRefreshModels(req, res);
+        });
+
+    httpServer_->Get(R"(/api/llm/providers/([^/]+)/health)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmGetProviderHealth(req, res);
+        });
+
+    // Admin routes for provider configuration
+    httpServer_->Get("/api/llm/admin/providers",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmAdminListProviders(req, res);
+        });
+
+    httpServer_->Post("/api/llm/admin/providers",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmAdminCreateProvider(req, res);
+        });
+
+    httpServer_->Put(R"(/api/llm/admin/providers/([^/]+))",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmAdminUpdateProvider(req, res);
+        });
+
+    httpServer_->Delete(R"(/api/llm/admin/providers/([^/]+))",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmAdminDeleteProvider(req, res);
+        });
+
+    // Workspace BYOK routes
+    httpServer_->Put(R"(/api/llm/workspaces/([^/]+)/key)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmSetWorkspaceKey(req, res);
+        });
+
+    httpServer_->Get(R"(/api/llm/workspaces/([^/]+)/key)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmGetWorkspaceKey(req, res);
+        });
+
+    httpServer_->Delete(R"(/api/llm/workspaces/([^/]+)/key)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmDeleteWorkspaceKey(req, res);
+        });
+
+    spdlog::info("LLM routes configured");
+}
+
+void HttpServer::HandleLlmCreateSession(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Check LLM chat permission
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    nlohmann::json body;
+    try {
+        if (!req.body.empty()) {
+            body = nlohmann::json::parse(req.body);
+        }
+    } catch (const nlohmann::json::parse_error&) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::CreateSessionRequest request;
+        request.set_user_id(auth_user->user_id);
+
+        if (body.contains("workspace_id") && body["workspace_id"].is_string()) {
+            request.set_workspace_id(body["workspace_id"].get<std::string>());
+        }
+        if (body.contains("title") && body["title"].is_string()) {
+            request.set_title(body["title"].get<std::string>());
+        }
+        if (body.contains("model_id") && body["model_id"].is_string()) {
+            request.set_model_id(body["model_id"].get<std::string>());
+        }
+        if (body.contains("system_prompt") && body["system_prompt"].is_string()) {
+            request.set_system_prompt(body["system_prompt"].get<std::string>());
+        }
+
+        auto [status, session] = llmClient_->CreateSession(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["id"] = session.id();
+        response["user_id"] = session.user_id();
+        response["workspace_id"] = session.workspace_id();
+        response["title"] = session.title();
+        response["model_id"] = session.model_id();
+        response["system_prompt"] = session.system_prompt();
+        response["created_at"] = session.created_at().seconds();
+        response["updated_at"] = session.updated_at().seconds();
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM create session error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmListSessions(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::ListSessionsRequest request;
+        request.set_user_id(auth_user->user_id);
+
+        if (req.has_param("workspace_id")) {
+            request.set_workspace_id(req.get_param_value("workspace_id"));
+        }
+        if (req.has_param("limit")) {
+            request.set_page_size(std::stoi(req.get_param_value("limit")));
+        }
+
+        auto [status, list_response] = llmClient_->ListSessions(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json sessions_array = nlohmann::json::array();
+        for (const auto& session : list_response.sessions()) {
+            nlohmann::json s;
+            s["id"] = session.id();
+            s["user_id"] = session.user_id();
+            s["workspace_id"] = session.workspace_id();
+            s["title"] = session.title();
+            s["model_id"] = session.model_id();
+            s["message_count"] = session.message_count();
+            s["created_at"] = session.created_at().seconds();
+            s["updated_at"] = session.updated_at().seconds();
+            sessions_array.push_back(s);
+        }
+
+        nlohmann::json response;
+        response["sessions"] = sessions_array;
+        response["total"] = list_response.total_count();
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM list sessions error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmGetSession(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    try {
+        ::smartbotic::llm::GetSessionRequest request;
+        request.set_session_id(session_id);
+        request.set_include_messages(true);
+
+        auto [status, session] = llmClient_->GetSession(request);
+
+        if (!status.ok()) {
+            if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+                res.status = 404;
+                res.set_content(R"({"error":"Session not found"})", "application/json");
+            } else {
+                res.status = 500;
+                nlohmann::json error = {{"error", status.error_message()}};
+                res.set_content(error.dump(), "application/json");
+            }
+            return;
+        }
+
+        // Verify ownership
+        if (session.user_id() != auth_user->user_id) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["id"] = session.id();
+        response["user_id"] = session.user_id();
+        response["workspace_id"] = session.workspace_id();
+        response["title"] = session.title();
+        response["model_id"] = session.model_id();
+        response["system_prompt"] = session.system_prompt();
+        response["created_at"] = session.created_at().seconds();
+        response["updated_at"] = session.updated_at().seconds();
+
+        nlohmann::json messages_array = nlohmann::json::array();
+        for (const auto& msg : session.messages()) {
+            nlohmann::json m;
+            m["id"] = msg.id();
+            m["role"] = ::smartbotic::llm::MessageRole_Name(msg.role());
+
+            // Extract text content from message parts
+            std::string msg_text;
+            for (const auto& part : msg.content()) {
+                if (part.has_text()) {
+                    msg_text += part.text();
+                }
+            }
+            m["content"] = msg_text;
+            m["created_at"] = msg.created_at().seconds();
+
+            if (msg.has_page_context()) {
+                m["page_context"] = {
+                    {"path", msg.page_context().path()},
+                    {"title", msg.page_context().title()},
+                    {"workspace_id", msg.page_context().workspace_id()},
+                    {"collection", msg.page_context().collection()},
+                    {"view_id", msg.page_context().view_id()}
+                };
+            }
+
+            messages_array.push_back(m);
+        }
+        response["messages"] = messages_array;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM get session error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmDeleteSession(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    try {
+        // First get session to verify ownership
+        ::smartbotic::llm::GetSessionRequest get_request;
+        get_request.set_session_id(session_id);
+
+        auto [get_status, session] = llmClient_->GetSession(get_request);
+        if (!get_status.ok()) {
+            res.status = 404;
+            res.set_content(R"({"error":"Session not found"})", "application/json");
+            return;
+        }
+
+        if (session.user_id() != auth_user->user_id) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
+            return;
+        }
+
+        ::smartbotic::llm::DeleteSessionRequest request;
+        request.set_session_id(session_id);
+
+        auto [status, delete_response] = llmClient_->DeleteSession(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        res.set_content(R"({"message":"Session deleted successfully"})", "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM delete session error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmSendMessage(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error&) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    if (!body.contains("content") || !body["content"].is_string()) {
+        res.status = 400;
+        res.set_content(R"({"error":"content is required"})", "application/json");
+        return;
+    }
+
+    try {
+        // Verify session ownership first
+        ::smartbotic::llm::GetSessionRequest get_request;
+        get_request.set_session_id(session_id);
+
+        auto [get_status, session] = llmClient_->GetSession(get_request);
+        if (!get_status.ok()) {
+            res.status = 404;
+            res.set_content(R"({"error":"Session not found"})", "application/json");
+            return;
+        }
+
+        if (session.user_id() != auth_user->user_id) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
+            return;
+        }
+
+        ::smartbotic::llm::ChatRequest request;
+        request.set_session_id(session_id);
+        request.set_user_id(auth_user->user_id);
+        auto* content_part = request.add_content();
+        content_part->set_text(body["content"].get<std::string>());
+
+        if (body.contains("page_context") && body["page_context"].is_object()) {
+            auto* ctx = request.mutable_page_context();
+            const auto& pc = body["page_context"];
+            if (pc.contains("path")) ctx->set_path(pc["path"].get<std::string>());
+            if (pc.contains("title")) ctx->set_title(pc["title"].get<std::string>());
+            if (pc.contains("workspace_id")) ctx->set_workspace_id(pc["workspace_id"].get<std::string>());
+            if (pc.contains("collection")) ctx->set_collection(pc["collection"].get<std::string>());
+            if (pc.contains("view_id")) ctx->set_view_id(pc["view_id"].get<std::string>());
+        }
+
+        auto [status, chat_response] = llmClient_->Chat(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["session_id"] = chat_response.session_id();
+        response["message_id"] = chat_response.assistant_message().id();
+
+        // Extract text content from assistant message
+        std::string content_text;
+        for (const auto& part : chat_response.assistant_message().content()) {
+            if (part.has_text()) {
+                content_text += part.text();
+            }
+        }
+        response["content"] = content_text;
+        response["finish_reason"] = static_cast<int>(chat_response.finish_reason());
+
+        if (chat_response.tool_calls_size() > 0) {
+            nlohmann::json tool_calls = nlohmann::json::array();
+            for (const auto& tc : chat_response.tool_calls()) {
+                tool_calls.push_back({
+                    {"id", tc.id()},
+                    {"name", tc.name()},
+                    {"arguments", tc.arguments()}
+                });
+            }
+            response["tool_calls"] = tool_calls;
+        }
+
+        response["usage"] = {
+            {"prompt_tokens", chat_response.usage().prompt_tokens()},
+            {"completion_tokens", chat_response.usage().completion_tokens()},
+            {"total_tokens", chat_response.usage().total_tokens()}
+        };
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM send message error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmStreamMessage(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error&) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    if (!body.contains("content") || !body["content"].is_string()) {
+        res.status = 400;
+        res.set_content(R"({"error":"content is required"})", "application/json");
+        return;
+    }
+
+    try {
+        // Verify session ownership first
+        ::smartbotic::llm::GetSessionRequest get_request;
+        get_request.set_session_id(session_id);
+
+        auto [get_status, session] = llmClient_->GetSession(get_request);
+        if (!get_status.ok()) {
+            res.status = 404;
+            res.set_content(R"({"error":"Session not found"})", "application/json");
+            return;
+        }
+
+        if (session.user_id() != auth_user->user_id) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
+            return;
+        }
+
+        ::smartbotic::llm::ChatRequest request;
+        request.set_session_id(session_id);
+        request.set_user_id(auth_user->user_id);
+        auto* stream_content_part = request.add_content();
+        stream_content_part->set_text(body["content"].get<std::string>());
+
+        if (body.contains("page_context") && body["page_context"].is_object()) {
+            auto* ctx = request.mutable_page_context();
+            const auto& pc = body["page_context"];
+            if (pc.contains("path")) ctx->set_path(pc["path"].get<std::string>());
+            if (pc.contains("title")) ctx->set_title(pc["title"].get<std::string>());
+            if (pc.contains("workspace_id")) ctx->set_workspace_id(pc["workspace_id"].get<std::string>());
+            if (pc.contains("collection")) ctx->set_collection(pc["collection"].get<std::string>());
+            if (pc.contains("view_id")) ctx->set_view_id(pc["view_id"].get<std::string>());
+        }
+
+        // Set up SSE response
+        res.set_header("Content-Type", "text/event-stream");
+        res.set_header("Cache-Control", "no-cache");
+        res.set_header("Connection", "keep-alive");
+
+        std::string sse_content;
+
+        auto stream_status = llmClient_->ChatStream(request,
+            [&sse_content](const ::smartbotic::llm::ChatStreamChunk& chunk) -> bool {
+                nlohmann::json event;
+                event["session_id"] = chunk.session_id();
+
+                // ChatStreamChunk uses oneof for content_delta, tool_call, or metadata
+                if (chunk.has_content_delta()) {
+                    event["delta"] = chunk.content_delta();
+                }
+                if (chunk.has_tool_call()) {
+                    event["tool_call"] = {
+                        {"id", chunk.tool_call().id()},
+                        {"name", chunk.tool_call().name()},
+                        {"arguments", chunk.tool_call().arguments()}
+                    };
+                }
+                if (chunk.has_metadata()) {
+                    const auto& meta = chunk.metadata();
+                    event["finish_reason"] = static_cast<int>(meta.finish_reason());
+                    event["message_id"] = meta.assistant_message().id();
+                }
+
+                sse_content += "data: " + event.dump() + "\n\n";
+                return true;
+            });
+
+        if (!stream_status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", stream_status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        // Send final done event
+        sse_content += "data: [DONE]\n\n";
+        res.set_content(sse_content, "text/event-stream");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM stream message error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmClearMessages(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    try {
+        // Verify session ownership first
+        ::smartbotic::llm::GetSessionRequest get_request;
+        get_request.set_session_id(session_id);
+
+        auto [get_status, session] = llmClient_->GetSession(get_request);
+        if (!get_status.ok()) {
+            res.status = 404;
+            res.set_content(R"({"error":"Session not found"})", "application/json");
+            return;
+        }
+
+        if (session.user_id() != auth_user->user_id) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
+            return;
+        }
+
+        ::smartbotic::llm::ClearSessionMessagesRequest request;
+        request.set_session_id(session_id);
+
+        auto [status, updated_session] = llmClient_->ClearSessionMessages(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        res.set_content(R"({"message":"Messages cleared successfully"})", "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM clear messages error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmListProviders(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::ListProvidersRequest request;
+        request.set_include_disabled(false);  // Only show enabled providers
+
+        auto [status, list_response] = llmClient_->ListProviders(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json providers_array = nlohmann::json::array();
+        for (const auto& provider : list_response.providers()) {
+            nlohmann::json p;
+            p["id"] = provider.id();
+            p["name"] = provider.name();
+            p["type"] = ::smartbotic::llm::ProviderType_Name(provider.type());
+            p["base_url"] = provider.base_url();
+            p["enabled"] = provider.enabled();
+            p["healthy"] = provider.healthy();
+            providers_array.push_back(p);
+        }
+
+        nlohmann::json response;
+        response["providers"] = providers_array;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM list providers error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmListModels(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::ListModelsRequest request;
+
+        if (req.has_param("provider_id")) {
+            request.set_provider_id(req.get_param_value("provider_id"));
+        }
+
+        auto [status, list_response] = llmClient_->ListModels(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json models_array = nlohmann::json::array();
+        for (const auto& model : list_response.models()) {
+            nlohmann::json m;
+            m["id"] = model.id();
+            m["name"] = model.name();
+            m["provider_id"] = model.provider_id();
+            m["provider_type"] = ::smartbotic::llm::ProviderType_Name(model.provider_type());
+            m["context_length"] = model.context_length();
+            m["supports_tools"] = model.supports_tools();
+            m["supports_vision"] = model.supports_vision();
+            models_array.push_back(m);
+        }
+
+        nlohmann::json response;
+        response["models"] = models_array;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM list models error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmRefreshModels(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmProvidersRead)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no provider access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::RefreshModelsRequest request;
+
+        nlohmann::json body;
+        if (!req.body.empty()) {
+            try {
+                body = nlohmann::json::parse(req.body);
+                if (body.contains("provider_id") && body["provider_id"].is_string()) {
+                    request.set_provider_id(body["provider_id"].get<std::string>());
+                }
+            } catch (...) {
+                // Ignore parse errors, proceed without provider_id filter
+            }
+        }
+
+        auto [status, refresh_response] = llmClient_->RefreshModels(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["models_found"] = refresh_response.models_found();
+
+        if (refresh_response.errors_size() > 0) {
+            nlohmann::json errors_array = nlohmann::json::array();
+            for (const auto& err : refresh_response.errors()) {
+                errors_array.push_back(err);
+            }
+            response["errors"] = errors_array;
+        }
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM refresh models error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmGetProviderHealth(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string provider_id = req.matches[1].str();
+
+    try {
+        ::smartbotic::llm::GetProviderHealthRequest request;
+        request.set_provider_id(provider_id);
+
+        auto [status, health_response] = llmClient_->GetProviderHealth(request);
+
+        if (!status.ok()) {
+            if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+                res.status = 404;
+                res.set_content(R"({"error":"Provider not found"})", "application/json");
+            } else {
+                res.status = 500;
+                nlohmann::json error = {{"error", status.error_message()}};
+                res.set_content(error.dump(), "application/json");
+            }
+            return;
+        }
+
+        nlohmann::json response;
+        response["provider_id"] = health_response.provider_id();
+        response["healthy"] = health_response.healthy();
+        response["latency_ms"] = health_response.latency_ms();
+        if (!health_response.error().empty()) {
+            response["error"] = health_response.error();
+        }
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM get provider health error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmAdminListProviders(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmProvidersRead)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no provider admin access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::ListProviderConfigsRequest request;
+        request.set_include_disabled(true);  // Admin sees all
+
+        auto [status, list_response] = llmClient_->ListProviderConfigs(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json providers_array = nlohmann::json::array();
+        for (const auto& provider : list_response.providers()) {
+            nlohmann::json p;
+            p["id"] = provider.id();
+            p["name"] = provider.name();
+            p["type"] = ::smartbotic::llm::ProviderType_Name(provider.type());
+            p["base_url"] = provider.base_url();
+            p["enabled"] = provider.enabled();
+            p["has_api_key"] = provider.has_api_key();
+            providers_array.push_back(p);
+        }
+
+        nlohmann::json response;
+        response["providers"] = providers_array;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM admin list providers error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmAdminCreateProvider(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmProvidersCreate)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no provider create access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error&) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    if (!body.contains("name") || !body["name"].is_string()) {
+        res.status = 400;
+        res.set_content(R"({"error":"name is required"})", "application/json");
+        return;
+    }
+
+    if (!body.contains("type") || !body["type"].is_string()) {
+        res.status = 400;
+        res.set_content(R"({"error":"type is required"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::SetProviderConfigRequest request;
+        // Leave id empty for new provider
+        request.set_name(body["name"].get<std::string>());
+
+        std::string type_str = body["type"].get<std::string>();
+        ::smartbotic::llm::ProviderType type;
+        if (!::smartbotic::llm::ProviderType_Parse(type_str, &type)) {
+            res.status = 400;
+            res.set_content(R"({"error":"Invalid provider type"})", "application/json");
+            return;
+        }
+        request.set_type(type);
+
+        if (body.contains("base_url") && body["base_url"].is_string()) {
+            request.set_base_url(body["base_url"].get<std::string>());
+        }
+        if (body.contains("api_key") && body["api_key"].is_string()) {
+            request.set_api_key(body["api_key"].get<std::string>());
+        }
+        request.set_enabled(body.value("enabled", true));
+
+        auto [status, provider] = llmClient_->SetProviderConfig(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["id"] = provider.id();
+        response["name"] = provider.name();
+        response["type"] = ::smartbotic::llm::ProviderType_Name(provider.type());
+        response["base_url"] = provider.base_url();
+        response["enabled"] = provider.enabled();
+        response["has_api_key"] = provider.has_api_key();
+
+        res.status = 201;
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM admin create provider error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmAdminUpdateProvider(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmProvidersUpdate)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no provider update access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string provider_id = req.matches[1].str();
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error&) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::SetProviderConfigRequest request;
+        request.set_id(provider_id);
+
+        if (body.contains("name") && body["name"].is_string()) {
+            request.set_name(body["name"].get<std::string>());
+        }
+        if (body.contains("type") && body["type"].is_string()) {
+            std::string type_str = body["type"].get<std::string>();
+            ::smartbotic::llm::ProviderType type;
+            if (::smartbotic::llm::ProviderType_Parse(type_str, &type)) {
+                request.set_type(type);
+            }
+        }
+        if (body.contains("base_url") && body["base_url"].is_string()) {
+            request.set_base_url(body["base_url"].get<std::string>());
+        }
+        if (body.contains("api_key") && body["api_key"].is_string()) {
+            request.set_api_key(body["api_key"].get<std::string>());
+        }
+        if (body.contains("enabled") && body["enabled"].is_boolean()) {
+            request.set_enabled(body["enabled"].get<bool>());
+        }
+
+        auto [status, provider] = llmClient_->SetProviderConfig(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["id"] = provider.id();
+        response["name"] = provider.name();
+        response["type"] = ::smartbotic::llm::ProviderType_Name(provider.type());
+        response["base_url"] = provider.base_url();
+        response["enabled"] = provider.enabled();
+        response["has_api_key"] = provider.has_api_key();
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM admin update provider error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmAdminDeleteProvider(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmProvidersDelete)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no provider delete access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string provider_id = req.matches[1].str();
+
+    try {
+        ::smartbotic::llm::DeleteProviderConfigRequest request;
+        request.set_provider_id(provider_id);
+
+        auto [status, delete_response] = llmClient_->DeleteProviderConfig(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        res.set_content(R"({"message":"Provider deleted successfully"})", "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM admin delete provider error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmSetWorkspaceKey(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    std::string workspace_id = req.matches[1].str();
+
+    // Check BYOK permission for this workspace
+    std::string byok_permission = permissions::BuildWorkspacePermission(workspace_id, "llm_byok");
+    if (!authorizationService_->HasPermission(*auth_user, byok_permission)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no BYOK access for this workspace"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error&) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    if (!body.contains("provider_id") || !body["provider_id"].is_string()) {
+        res.status = 400;
+        res.set_content(R"({"error":"provider_id is required"})", "application/json");
+        return;
+    }
+
+    if (!body.contains("api_key") || !body["api_key"].is_string()) {
+        res.status = 400;
+        res.set_content(R"({"error":"api_key is required"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::SetWorkspaceApiKeyRequest request;
+        request.set_workspace_id(workspace_id);
+        request.set_provider_id(body["provider_id"].get<std::string>());
+        request.set_api_key(body["api_key"].get<std::string>());
+
+        auto status = llmClient_->SetWorkspaceApiKey(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        res.set_content(R"({"message":"Workspace API key set successfully"})", "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM set workspace key error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmGetWorkspaceKey(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    std::string workspace_id = req.matches[1].str();
+
+    // Check BYOK permission for this workspace
+    std::string byok_permission = permissions::BuildWorkspacePermission(workspace_id, "llm_byok");
+    if (!authorizationService_->HasPermission(*auth_user, byok_permission)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no BYOK access for this workspace"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string provider_id;
+    if (req.has_param("provider_id")) {
+        provider_id = req.get_param_value("provider_id");
+    } else {
+        res.status = 400;
+        res.set_content(R"({"error":"provider_id query parameter is required"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::GetWorkspaceApiKeyRequest request;
+        request.set_workspace_id(workspace_id);
+        request.set_provider_id(provider_id);
+
+        auto [status, key_response] = llmClient_->GetWorkspaceApiKey(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["workspace_id"] = key_response.workspace_id();
+        response["provider_id"] = key_response.provider_id();
+        response["has_api_key"] = key_response.has_api_key();
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM get workspace key error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmDeleteWorkspaceKey(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    std::string workspace_id = req.matches[1].str();
+
+    // Check BYOK permission for this workspace
+    std::string byok_permission = permissions::BuildWorkspacePermission(workspace_id, "llm_byok");
+    if (!authorizationService_->HasPermission(*auth_user, byok_permission)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no BYOK access for this workspace"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string provider_id;
+    if (req.has_param("provider_id")) {
+        provider_id = req.get_param_value("provider_id");
+    } else {
+        res.status = 400;
+        res.set_content(R"({"error":"provider_id query parameter is required"})", "application/json");
+        return;
+    }
+
+    try {
+        ::smartbotic::llm::DeleteWorkspaceApiKeyRequest request;
+        request.set_workspace_id(workspace_id);
+        request.set_provider_id(provider_id);
+
+        auto [status, delete_response] = llmClient_->DeleteWorkspaceApiKey(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        res.set_content(R"({"message":"Workspace API key deleted successfully"})", "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM delete workspace key error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
 }  // namespace smartbotic::webserver

+ 312 - 0
webserver/src/llm_client.cpp

@@ -0,0 +1,312 @@
+#include "smartbotic/webserver/llm_client.hpp"
+
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::webserver {
+
+LlmClient::LlmClient(LlmClientConfig config) : config_(std::move(config)) {}
+
+LlmClient::~LlmClient() {
+    Disconnect();
+}
+
+void LlmClient::CreateChannel() {
+    grpc::ChannelArguments args;
+    args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 10000);
+    args.SetInt(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 5000);
+    args.SetInt(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);
+
+    channel_ = grpc::CreateCustomChannel(
+        config_.address, grpc::InsecureChannelCredentials(), args);
+}
+
+void LlmClient::CreateStubs() {
+    llm_stub_ = ::smartbotic::llm::LLMService::NewStub(channel_);
+    session_stub_ = ::smartbotic::llm::SessionService::NewStub(channel_);
+    model_stub_ = ::smartbotic::llm::ModelService::NewStub(channel_);
+    config_stub_ = ::smartbotic::llm::ConfigService::NewStub(channel_);
+}
+
+auto LlmClient::Connect() -> bool {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    if (connected_) {
+        return true;
+    }
+
+    spdlog::info("Connecting to LLM service at {}", config_.address);
+
+    CreateChannel();
+
+    // Wait for connection
+    auto deadline = std::chrono::system_clock::now() + config_.connect_timeout;
+    if (!channel_->WaitForConnected(deadline)) {
+        spdlog::error("Failed to connect to LLM service");
+        return false;
+    }
+
+    CreateStubs();
+    connected_ = true;
+
+    spdlog::info("Connected to LLM service");
+    return true;
+}
+
+void LlmClient::Disconnect() {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    llm_stub_.reset();
+    session_stub_.reset();
+    model_stub_.reset();
+    config_stub_.reset();
+    channel_.reset();
+    connected_ = false;
+}
+
+auto LlmClient::IsConnected() const -> bool {
+    return connected_.load();
+}
+
+// =========================================================================
+// LLM Service Operations
+// =========================================================================
+
+auto LlmClient::Chat(const ::smartbotic::llm::ChatRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::ChatResponse> {
+    ::smartbotic::llm::ChatResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = llm_stub_->Chat(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::ChatStream(const ::smartbotic::llm::ChatRequest& request,
+                           StreamChunkCallback callback) -> grpc::Status {
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.stream_timeout);
+
+    auto reader = llm_stub_->ChatStream(&context, request);
+
+    ::smartbotic::llm::ChatStreamChunk chunk;
+    while (reader->Read(&chunk)) {
+        if (!callback(chunk)) {
+            context.TryCancel();
+            break;
+        }
+    }
+
+    return reader->Finish();
+}
+
+auto LlmClient::SubmitToolResults(const ::smartbotic::llm::SubmitToolResultsRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::ChatResponse> {
+    ::smartbotic::llm::ChatResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = llm_stub_->SubmitToolResults(&context, request, &response);
+    return {status, response};
+}
+
+// =========================================================================
+// Session Service Operations
+// =========================================================================
+
+auto LlmClient::CreateSession(const ::smartbotic::llm::CreateSessionRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::Session> {
+    ::smartbotic::llm::Session response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = session_stub_->CreateSession(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::GetSession(const ::smartbotic::llm::GetSessionRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::Session> {
+    ::smartbotic::llm::Session response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = session_stub_->GetSession(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::ListSessions(const ::smartbotic::llm::ListSessionsRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::ListSessionsResponse> {
+    ::smartbotic::llm::ListSessionsResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = session_stub_->ListSessions(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::UpdateSession(const ::smartbotic::llm::UpdateSessionRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::Session> {
+    ::smartbotic::llm::Session response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = session_stub_->UpdateSession(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::DeleteSession(const ::smartbotic::llm::DeleteSessionRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::DeleteSessionResponse> {
+    ::smartbotic::llm::DeleteSessionResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = session_stub_->DeleteSession(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::ClearSessionMessages(const ::smartbotic::llm::ClearSessionMessagesRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::Session> {
+    ::smartbotic::llm::Session response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = session_stub_->ClearSessionMessages(&context, request, &response);
+    return {status, response};
+}
+
+// =========================================================================
+// Model Service Operations
+// =========================================================================
+
+auto LlmClient::ListProviders(const ::smartbotic::llm::ListProvidersRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::ListProvidersResponse> {
+    ::smartbotic::llm::ListProvidersResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = model_stub_->ListProviders(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::ListModels(const ::smartbotic::llm::ListModelsRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::ListModelsResponse> {
+    ::smartbotic::llm::ListModelsResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = model_stub_->ListModels(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::RefreshModels(const ::smartbotic::llm::RefreshModelsRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::RefreshModelsResponse> {
+    ::smartbotic::llm::RefreshModelsResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = model_stub_->RefreshModels(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::GetProviderHealth(const ::smartbotic::llm::GetProviderHealthRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::GetProviderHealthResponse> {
+    ::smartbotic::llm::GetProviderHealthResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = model_stub_->GetProviderHealth(&context, request, &response);
+    return {status, response};
+}
+
+// =========================================================================
+// Config Service Operations
+// =========================================================================
+
+auto LlmClient::GetProviderConfig(const ::smartbotic::llm::GetProviderConfigRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::ProviderConfig> {
+    ::smartbotic::llm::ProviderConfig response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = config_stub_->GetProviderConfig(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::SetProviderConfig(const ::smartbotic::llm::SetProviderConfigRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::ProviderConfig> {
+    ::smartbotic::llm::ProviderConfig response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = config_stub_->SetProviderConfig(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::DeleteProviderConfig(const ::smartbotic::llm::DeleteProviderConfigRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::DeleteProviderConfigResponse> {
+    ::smartbotic::llm::DeleteProviderConfigResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = config_stub_->DeleteProviderConfig(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::ListProviderConfigs(const ::smartbotic::llm::ListProviderConfigsRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::ListProviderConfigsResponse> {
+    ::smartbotic::llm::ListProviderConfigsResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = config_stub_->ListProviderConfigs(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::SetWorkspaceApiKey(const ::smartbotic::llm::SetWorkspaceApiKeyRequest& request)
+    -> grpc::Status {
+    ::smartbotic::llm::Empty response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    return config_stub_->SetWorkspaceApiKey(&context, request, &response);
+}
+
+auto LlmClient::GetWorkspaceApiKey(const ::smartbotic::llm::GetWorkspaceApiKeyRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::WorkspaceApiKeyResponse> {
+    ::smartbotic::llm::WorkspaceApiKeyResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = config_stub_->GetWorkspaceApiKey(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::DeleteWorkspaceApiKey(const ::smartbotic::llm::DeleteWorkspaceApiKeyRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::DeleteWorkspaceApiKeyResponse> {
+    ::smartbotic::llm::DeleteWorkspaceApiKeyResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = config_stub_->DeleteWorkspaceApiKey(&context, request, &response);
+    return {status, response};
+}
+
+}  // namespace smartbotic::webserver

+ 9 - 0
webserver/src/permissions.cpp

@@ -185,6 +185,15 @@ auto GetAllSystemPermissions() -> std::vector<std::string_view> {
         kPagesCreate,
         kPagesUpdate,
         kPagesDelete,
+
+        // LLM Provider management
+        kLlmProvidersRead,
+        kLlmProvidersCreate,
+        kLlmProvidersUpdate,
+        kLlmProvidersDelete,
+
+        // LLM Chat access
+        kLlmChat,
     };
 }
 

+ 2 - 0
webui/src/App.tsx

@@ -13,6 +13,7 @@ import Users from '@/pages/Users'
 import Groups from '@/pages/Groups'
 import ApiKeys from '@/pages/ApiKeys'
 import Workspaces from '@/pages/Workspaces'
+import LlmProviders from '@/pages/LlmProviders'
 import ProtectedRoute from '@/components/ProtectedRoute'
 import { DashboardLayout } from '@/components/layout'
 
@@ -72,6 +73,7 @@ function App() {
         <Route path="/groups" element={<Groups />} />
         <Route path="/api-keys" element={<ApiKeys />} />
         <Route path="/workspaces" element={<Workspaces />} />
+        <Route path="/llm-providers" element={<LlmProviders />} />
       </Route>
 
       {/* Catch-all redirect */}

+ 168 - 0
webui/src/api/chat.ts

@@ -0,0 +1,168 @@
+// Chat API client for LLM assistant
+
+import { apiClient } from './client'
+import type {
+  ChatSession,
+  ChatResponse,
+  LlmProvider,
+  LlmModel,
+  CreateSessionRequest,
+  SendMessageRequest,
+  PageContext,
+  CreateProviderRequest,
+  UpdateProviderRequest,
+  SetWorkspaceKeyRequest,
+} from '../types/chat'
+
+// Session management
+
+export async function createSession(request: CreateSessionRequest = {}): Promise<ChatSession> {
+  return apiClient.post<ChatSession>('/llm/sessions', request)
+}
+
+export async function listSessions(workspaceId?: string, limit?: number): Promise<{ sessions: ChatSession[], total: number }> {
+  const params = new URLSearchParams()
+  if (workspaceId) params.set('workspace_id', workspaceId)
+  if (limit) params.set('limit', limit.toString())
+  const query = params.toString()
+  return apiClient.get<{ sessions: ChatSession[], total: number }>(`/llm/sessions${query ? `?${query}` : ''}`)
+}
+
+export async function getSession(sessionId: string): Promise<ChatSession> {
+  return apiClient.get<ChatSession>(`/llm/sessions/${sessionId}`)
+}
+
+export async function deleteSession(sessionId: string): Promise<{ message: string }> {
+  return apiClient.delete<{ message: string }>(`/llm/sessions/${sessionId}`)
+}
+
+export async function clearSessionMessages(sessionId: string): Promise<{ message: string }> {
+  return apiClient.post<{ message: string }>(`/llm/sessions/${sessionId}/clear`)
+}
+
+// Chat messages
+
+export async function sendMessage(sessionId: string, request: SendMessageRequest): Promise<ChatResponse> {
+  return apiClient.post<ChatResponse>(`/llm/sessions/${sessionId}/messages`, request)
+}
+
+export async function streamMessage(
+  sessionId: string,
+  request: SendMessageRequest,
+  onChunk: (chunk: { delta?: string; finish_reason?: string; message_id?: string; session_id: string }) => void,
+  onError?: (error: Error) => void
+): Promise<void> {
+  const accessToken = apiClient.getAccessToken()
+
+  const response = await fetch(`/api/llm/sessions/${sessionId}/stream`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      ...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}),
+    },
+    body: JSON.stringify(request),
+  })
+
+  if (!response.ok) {
+    const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
+    throw new Error(errorData.error || `HTTP ${response.status}`)
+  }
+
+  const reader = response.body?.getReader()
+  if (!reader) {
+    throw new Error('No response body')
+  }
+
+  const decoder = new TextDecoder()
+  let buffer = ''
+
+  try {
+    while (true) {
+      const { done, value } = await reader.read()
+      if (done) break
+
+      buffer += decoder.decode(value, { stream: true })
+
+      // Process SSE events
+      const lines = buffer.split('\n')
+      buffer = lines.pop() || '' // Keep incomplete line in buffer
+
+      for (const line of lines) {
+        if (line.startsWith('data: ')) {
+          const data = line.slice(6).trim()
+          if (data === '[DONE]') {
+            return
+          }
+          try {
+            const chunk = JSON.parse(data)
+            onChunk(chunk)
+          } catch {
+            // Ignore parse errors
+          }
+        }
+      }
+    }
+  } catch (error) {
+    if (onError && error instanceof Error) {
+      onError(error)
+    }
+    throw error
+  }
+}
+
+// Models and providers
+
+export async function listProviders(): Promise<{ providers: LlmProvider[] }> {
+  return apiClient.get<{ providers: LlmProvider[] }>('/llm/providers')
+}
+
+export async function listModels(providerId?: string): Promise<{ models: LlmModel[] }> {
+  const query = providerId ? `?provider_id=${providerId}` : ''
+  return apiClient.get<{ models: LlmModel[] }>(`/llm/models${query}`)
+}
+
+export async function refreshModels(providerId?: string): Promise<{ models_found: number; errors?: string[] }> {
+  return apiClient.post<{ models_found: number; errors?: string[] }>('/llm/models/refresh', providerId ? { provider_id: providerId } : undefined)
+}
+
+export async function getProviderHealth(providerId: string): Promise<{ provider_id: string; healthy: boolean; latency_ms: number; error?: string }> {
+  return apiClient.get<{ provider_id: string; healthy: boolean; latency_ms: number; error?: string }>(`/llm/providers/${providerId}/health`)
+}
+
+// Admin - Provider management
+
+export async function adminListProviders(): Promise<{ providers: LlmProvider[] }> {
+  return apiClient.get<{ providers: LlmProvider[] }>('/llm/admin/providers')
+}
+
+export async function adminCreateProvider(request: CreateProviderRequest): Promise<LlmProvider> {
+  return apiClient.post<LlmProvider>('/llm/admin/providers', request)
+}
+
+export async function adminUpdateProvider(providerId: string, request: UpdateProviderRequest): Promise<LlmProvider> {
+  return apiClient.request<LlmProvider>(`/llm/admin/providers/${providerId}`, {
+    method: 'PUT',
+    body: request,
+  })
+}
+
+export async function adminDeleteProvider(providerId: string): Promise<{ message: string }> {
+  return apiClient.delete<{ message: string }>(`/llm/admin/providers/${providerId}`)
+}
+
+// Workspace BYOK
+
+export async function setWorkspaceApiKey(workspaceId: string, request: SetWorkspaceKeyRequest): Promise<{ message: string }> {
+  return apiClient.request<{ message: string }>(`/llm/workspaces/${workspaceId}/key`, {
+    method: 'PUT',
+    body: request,
+  })
+}
+
+export async function getWorkspaceApiKey(workspaceId: string, providerId: string): Promise<{ workspace_id: string; provider_id: string; has_api_key: boolean }> {
+  return apiClient.get<{ workspace_id: string; provider_id: string; has_api_key: boolean }>(`/llm/workspaces/${workspaceId}/key?provider_id=${providerId}`)
+}
+
+export async function deleteWorkspaceApiKey(workspaceId: string, providerId: string): Promise<{ message: string }> {
+  return apiClient.delete<{ message: string }>(`/llm/workspaces/${workspaceId}/key?provider_id=${providerId}`)
+}

+ 29 - 0
webui/src/components/Chat/ChatIcon.tsx

@@ -0,0 +1,29 @@
+// Minimized chat icon button (FAB)
+
+import { MessageSquare } from 'lucide-react'
+import { useChat } from '@/contexts/ChatContext'
+import { usePermissions } from '@/hooks/usePermissions'
+
+export function ChatIcon() {
+  const { toggleChat, isMinimized } = useChat()
+  const { hasPermission } = usePermissions()
+
+  // Only show if user has LLM chat permission
+  if (!hasPermission('system:llm:chat')) {
+    return null
+  }
+
+  if (!isMinimized) {
+    return null
+  }
+
+  return (
+    <button
+      onClick={toggleChat}
+      className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition-all hover:bg-blue-700 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
+      aria-label="Open chat assistant"
+    >
+      <MessageSquare className="h-6 w-6" />
+    </button>
+  )
+}

+ 74 - 0
webui/src/components/Chat/ChatInput.tsx

@@ -0,0 +1,74 @@
+// Chat input field with send button
+
+import { useState, useRef, KeyboardEvent } from 'react'
+import { Send, Loader2 } from 'lucide-react'
+import { useChat } from '@/contexts/ChatContext'
+
+export function ChatInput() {
+  const [input, setInput] = useState('')
+  const textareaRef = useRef<HTMLTextAreaElement>(null)
+  const { sendMessage, isStreaming } = useChat()
+
+  const handleSubmit = async () => {
+    const content = input.trim()
+    if (!content || isStreaming) return
+
+    setInput('')
+    if (textareaRef.current) {
+      textareaRef.current.style.height = 'auto'
+    }
+
+    try {
+      await sendMessage(content)
+    } catch (error) {
+      console.error('Failed to send message:', error)
+      setInput(content) // Restore input on error
+    }
+  }
+
+  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key === 'Enter' && !e.shiftKey) {
+      e.preventDefault()
+      handleSubmit()
+    }
+  }
+
+  const handleInput = () => {
+    if (textareaRef.current) {
+      textareaRef.current.style.height = 'auto'
+      textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`
+    }
+  }
+
+  return (
+    <div className="border-t border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
+      <div className="flex items-end gap-2">
+        <textarea
+          ref={textareaRef}
+          value={input}
+          onChange={(e) => {
+            setInput(e.target.value)
+            handleInput()
+          }}
+          onKeyDown={handleKeyDown}
+          placeholder="Type a message..."
+          disabled={isStreaming}
+          rows={1}
+          className="flex-1 resize-none rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:focus:border-blue-400"
+        />
+        <button
+          onClick={handleSubmit}
+          disabled={!input.trim() || isStreaming}
+          className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-600 text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-300 dark:disabled:bg-gray-600"
+          aria-label="Send message"
+        >
+          {isStreaming ? (
+            <Loader2 className="h-5 w-5 animate-spin" />
+          ) : (
+            <Send className="h-5 w-5" />
+          )}
+        </button>
+      </div>
+    </div>
+  )
+}

+ 71 - 0
webui/src/components/Chat/ChatMessage.tsx

@@ -0,0 +1,71 @@
+// Individual chat message bubble
+
+import { useMemo } from 'react'
+import { User, Bot } from 'lucide-react'
+import type { ChatMessage as ChatMessageType } from '@/types/chat'
+
+interface ChatMessageProps {
+  message: ChatMessageType
+  isStreaming?: boolean
+  streamingContent?: string
+}
+
+export function ChatMessage({ message, isStreaming, streamingContent }: ChatMessageProps) {
+  const isUser = message.role === 'user'
+  const isAssistant = message.role === 'assistant'
+
+  const content = useMemo(() => {
+    if (isStreaming && streamingContent) {
+      return streamingContent
+    }
+    return message.content
+  }, [isStreaming, streamingContent, message.content])
+
+  const formattedTime = useMemo(() => {
+    const date = new Date(message.created_at * 1000)
+    return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+  }, [message.created_at])
+
+  return (
+    <div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
+      {/* Avatar */}
+      <div
+        className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
+          isUser ? 'bg-blue-600' : 'bg-gray-600'
+        }`}
+      >
+        {isUser ? (
+          <User className="h-4 w-4 text-white" />
+        ) : (
+          <Bot className="h-4 w-4 text-white" />
+        )}
+      </div>
+
+      {/* Message content */}
+      <div className={`flex max-w-[80%] flex-col ${isUser ? 'items-end' : 'items-start'}`}>
+        <div
+          className={`rounded-2xl px-4 py-2 ${
+            isUser
+              ? 'bg-blue-600 text-white'
+              : 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
+          }`}
+        >
+          <p className="whitespace-pre-wrap text-sm">{content}</p>
+          {isStreaming && (
+            <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-current opacity-75" />
+          )}
+        </div>
+
+        {/* Timestamp and context info */}
+        <div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
+          <span>{formattedTime}</span>
+          {message.page_context && isUser && (
+            <span className="rounded bg-gray-200 px-1.5 py-0.5 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
+              {message.page_context.title}
+            </span>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 52 - 0
webui/src/components/Chat/ChatMessageList.tsx

@@ -0,0 +1,52 @@
+// Chat message list container with auto-scroll
+
+import { useRef, useEffect } from 'react'
+import { useChat } from '@/contexts/ChatContext'
+import { ChatMessage } from './ChatMessage'
+import { Loader2 } from 'lucide-react'
+
+export function ChatMessageList() {
+  const { messages, isStreaming, streamingContent } = useChat()
+  const messagesEndRef = useRef<HTMLDivElement>(null)
+
+  // Auto-scroll to bottom when new messages arrive or during streaming
+  useEffect(() => {
+    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+  }, [messages, streamingContent])
+
+  if (messages.length === 0 && !isStreaming) {
+    return (
+      <div className="flex flex-1 items-center justify-center p-8 text-center">
+        <div className="text-gray-500 dark:text-gray-400">
+          <p className="text-lg font-medium">Start a conversation</p>
+          <p className="mt-1 text-sm">Ask me anything about SmartBotic CRM</p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex-1 overflow-y-auto p-4">
+      <div className="flex flex-col gap-4">
+        {messages.map((message, index) => (
+          <ChatMessage
+            key={message.id || index}
+            message={message}
+            isStreaming={isStreaming && index === messages.length - 1 && message.role === 'assistant'}
+            streamingContent={index === messages.length - 1 ? streamingContent : undefined}
+          />
+        ))}
+
+        {/* Streaming indicator when no assistant message yet */}
+        {isStreaming && (messages.length === 0 || messages[messages.length - 1]?.role === 'user') && (
+          <div className="flex items-center gap-2 text-gray-500">
+            <Loader2 className="h-4 w-4 animate-spin" />
+            <span className="text-sm">Thinking...</span>
+          </div>
+        )}
+
+        <div ref={messagesEndRef} />
+      </div>
+    </div>
+  )
+}

+ 64 - 0
webui/src/components/Chat/ChatWindow.tsx

@@ -0,0 +1,64 @@
+// Full chat window component
+
+import { X, Minus, Settings } from 'lucide-react'
+import { useChat } from '@/contexts/ChatContext'
+import { SessionSidebar } from './SessionSidebar'
+import { ChatMessageList } from './ChatMessageList'
+import { ChatInput } from './ChatInput'
+
+export function ChatWindow() {
+  const { isOpen, isMinimized, minimizeChat, closeChat, models, selectedModelId, setSelectedModelId } = useChat()
+
+  if (!isOpen || isMinimized) {
+    return null
+  }
+
+  return (
+    <div className="fixed bottom-6 right-6 z-50 flex h-[600px] w-[400px] flex-col overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-800 md:h-[600px] md:w-[400px]">
+      {/* Header */}
+      <div className="flex items-center justify-between border-b border-gray-200 bg-blue-600 px-4 py-3 dark:border-gray-700">
+        <div className="flex items-center gap-2">
+          <h2 className="font-semibold text-white">AI Assistant</h2>
+          {models.length > 0 && (
+            <select
+              value={selectedModelId || ''}
+              onChange={(e) => setSelectedModelId(e.target.value)}
+              className="rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-400 focus:outline-none focus:ring-2 focus:ring-white"
+            >
+              {models.map((model) => (
+                <option key={model.id} value={model.id}>
+                  {model.name}
+                </option>
+              ))}
+            </select>
+          )}
+        </div>
+        <div className="flex items-center gap-1">
+          <button
+            onClick={minimizeChat}
+            className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white"
+            aria-label="Minimize chat"
+          >
+            <Minus className="h-4 w-4" />
+          </button>
+          <button
+            onClick={closeChat}
+            className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white"
+            aria-label="Close chat"
+          >
+            <X className="h-4 w-4" />
+          </button>
+        </div>
+      </div>
+
+      {/* Session sidebar */}
+      <SessionSidebar />
+
+      {/* Messages */}
+      <ChatMessageList />
+
+      {/* Input */}
+      <ChatInput />
+    </div>
+  )
+}

+ 136 - 0
webui/src/components/Chat/SessionSidebar.tsx

@@ -0,0 +1,136 @@
+// Session list sidebar panel
+
+import { useState } from 'react'
+import { MessageSquarePlus, Trash2, ChevronRight, ChevronDown, Loader2 } from 'lucide-react'
+import { useChat } from '@/contexts/ChatContext'
+
+export function SessionSidebar() {
+  const [isExpanded, setIsExpanded] = useState(false)
+  const [deletingId, setDeletingId] = useState<string | null>(null)
+  const {
+    sessions,
+    currentSession,
+    isLoadingSessions,
+    createSession,
+    switchSession,
+    deleteSession,
+  } = useChat()
+
+  const handleCreateSession = async () => {
+    try {
+      await createSession()
+    } catch (error) {
+      console.error('Failed to create session:', error)
+    }
+  }
+
+  const handleDeleteSession = async (e: React.MouseEvent, sessionId: string) => {
+    e.stopPropagation()
+    setDeletingId(sessionId)
+    try {
+      await deleteSession(sessionId)
+    } catch (error) {
+      console.error('Failed to delete session:', error)
+    } finally {
+      setDeletingId(null)
+    }
+  }
+
+  const formatDate = (timestamp: number) => {
+    const date = new Date(timestamp * 1000)
+    const now = new Date()
+    const diff = now.getTime() - date.getTime()
+    const days = Math.floor(diff / (1000 * 60 * 60 * 24))
+
+    if (days === 0) {
+      return 'Today'
+    } else if (days === 1) {
+      return 'Yesterday'
+    } else if (days < 7) {
+      return `${days} days ago`
+    } else {
+      return date.toLocaleDateString()
+    }
+  }
+
+  return (
+    <div className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
+      {/* Header */}
+      <button
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
+      >
+        <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
+          Sessions ({sessions.length})
+        </span>
+        {isExpanded ? (
+          <ChevronDown className="h-4 w-4 text-gray-500" />
+        ) : (
+          <ChevronRight className="h-4 w-4 text-gray-500" />
+        )}
+      </button>
+
+      {/* Expanded content */}
+      {isExpanded && (
+        <div className="max-h-48 overflow-y-auto border-t border-gray-200 dark:border-gray-700">
+          {/* New session button */}
+          <button
+            onClick={handleCreateSession}
+            className="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:text-blue-400 dark:hover:bg-gray-700"
+          >
+            <MessageSquarePlus className="h-4 w-4" />
+            New conversation
+          </button>
+
+          {/* Loading state */}
+          {isLoadingSessions && (
+            <div className="flex items-center justify-center py-4">
+              <Loader2 className="h-5 w-5 animate-spin text-gray-400" />
+            </div>
+          )}
+
+          {/* Session list */}
+          {!isLoadingSessions && sessions.map((session) => (
+            <div
+              key={session.id}
+              onClick={() => switchSession(session.id)}
+              className={`group flex cursor-pointer items-center justify-between px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 ${
+                currentSession?.id === session.id
+                  ? 'bg-blue-50 dark:bg-blue-900/20'
+                  : ''
+              }`}
+            >
+              <div className="min-w-0 flex-1">
+                <p className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
+                  {session.title || 'New conversation'}
+                </p>
+                <p className="text-xs text-gray-500 dark:text-gray-400">
+                  {formatDate(session.updated_at)}
+                </p>
+              </div>
+              <button
+                onClick={(e) => handleDeleteSession(e, session.id)}
+                disabled={deletingId === session.id}
+                className="ml-2 hidden rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-red-500 group-hover:block dark:hover:bg-gray-600 disabled:opacity-50"
+                aria-label="Delete session"
+              >
+                {deletingId === session.id ? (
+                  <Loader2 className="h-4 w-4 animate-spin" />
+                ) : (
+                  <Trash2 className="h-4 w-4" />
+                )}
+              </button>
+            </div>
+          ))}
+
+          {/* Empty state */}
+          {!isLoadingSessions && sessions.length === 0 && (
+            <div className="px-4 py-4 text-center text-sm text-gray-500 dark:text-gray-400">
+              No conversations yet
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}

+ 29 - 0
webui/src/components/Chat/index.tsx

@@ -0,0 +1,29 @@
+// Chat widget - combines icon and window
+
+import { ChatIcon } from './ChatIcon'
+import { ChatWindow } from './ChatWindow'
+import { usePermissions } from '@/hooks/usePermissions'
+
+export function ChatWidget() {
+  const { hasPermission } = usePermissions()
+
+  // Only show if user has LLM chat permission
+  if (!hasPermission('system:llm:chat')) {
+    return null
+  }
+
+  return (
+    <>
+      <ChatIcon />
+      <ChatWindow />
+    </>
+  )
+}
+
+// Re-export individual components for flexibility
+export { ChatIcon } from './ChatIcon'
+export { ChatWindow } from './ChatWindow'
+export { ChatMessage } from './ChatMessage'
+export { ChatMessageList } from './ChatMessageList'
+export { ChatInput } from './ChatInput'
+export { SessionSidebar } from './SessionSidebar'

+ 8 - 0
webui/src/components/layout/DashboardLayout.tsx

@@ -4,10 +4,15 @@ import { useState } from 'react'
 import { Outlet } from 'react-router-dom'
 import Sidebar from './Sidebar'
 import TopBar from './TopBar'
+import { ChatWidget } from '@/components/Chat'
+import { usePageContext } from '@/hooks/usePageContext'
 
 function DashboardLayout() {
   const [isSidebarOpen, setIsSidebarOpen] = useState(false)
 
+  // Track current page context for the LLM assistant
+  usePageContext()
+
   return (
     <div className="flex h-screen bg-gray-50">
       {/* Sidebar */}
@@ -23,6 +28,9 @@ function DashboardLayout() {
           <Outlet />
         </main>
       </div>
+
+      {/* Chat widget (fixed position) */}
+      <ChatWidget />
     </div>
   )
 }

+ 28 - 0
webui/src/components/layout/Sidebar.tsx

@@ -2,6 +2,7 @@
 
 import { NavLink } from 'react-router-dom'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { useAuth } from '@/contexts/AuthContext'
 import { useSidebarPages } from '@/hooks/useSidebarPages'
 import type { Workspace } from '@/types'
 
@@ -234,6 +235,7 @@ function WorkspaceSelector() {
 }
 
 function Sidebar({ isOpen, onClose }: SidebarProps) {
+  const { user } = useAuth()
   const { currentWorkspace } = useWorkspace()
   const { pages: sidebarPages, isLoading: pagesLoading } = useSidebarPages({
     workspaceId: currentWorkspace?.id,
@@ -295,6 +297,32 @@ function Sidebar({ isOpen, onClose }: SidebarProps) {
                 </NavLink>
               </li>
             ))}
+            {/* LLM Providers - only visible for superadmins */}
+            {user?.is_superadmin && (
+              <li>
+                <NavLink
+                  to="/llm-providers"
+                  onClick={onClose}
+                  className={({ isActive }) =>
+                    `flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition ${
+                      isActive
+                        ? 'bg-primary-50 text-primary-700'
+                        : 'text-gray-700 hover:bg-gray-100'
+                    }`
+                  }
+                >
+                  <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                    <path
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                      strokeWidth={2}
+                      d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
+                    />
+                  </svg>
+                  LLM Providers
+                </NavLink>
+              </li>
+            )}
           </ul>
 
           {/* Dynamic Pages Section */}

+ 309 - 0
webui/src/contexts/ChatContext.tsx

@@ -0,0 +1,309 @@
+// Chat context for LLM assistant state management
+
+import { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react'
+import * as chatApi from '@/api/chat'
+import type { ChatSession, ChatMessage, PageContext, LlmModel } from '@/types/chat'
+import { useAuth } from './AuthContext'
+
+interface ChatContextValue {
+  // UI State
+  isOpen: boolean
+  isMinimized: boolean
+  toggleChat: () => void
+  minimizeChat: () => void
+  expandChat: () => void
+  closeChat: () => void
+
+  // Session Management
+  sessions: ChatSession[]
+  currentSession: ChatSession | null
+  isLoadingSessions: boolean
+  createSession: (workspaceId?: string) => Promise<ChatSession>
+  switchSession: (sessionId: string) => Promise<void>
+  deleteSession: (sessionId: string) => Promise<void>
+  refreshSessions: () => Promise<void>
+
+  // Messages
+  messages: ChatMessage[]
+  isStreaming: boolean
+  streamingContent: string
+  sendMessage: (content: string) => Promise<void>
+  clearMessages: () => Promise<void>
+
+  // Models
+  models: LlmModel[]
+  selectedModelId: string | null
+  setSelectedModelId: (modelId: string) => void
+  loadModels: () => Promise<void>
+
+  // Page Context
+  currentPageContext: PageContext | null
+  setCurrentPageContext: (context: PageContext | null) => void
+}
+
+const ChatContext = createContext<ChatContextValue | null>(null)
+
+interface ChatProviderProps {
+  children: ReactNode
+}
+
+export function ChatProvider({ children }: ChatProviderProps) {
+  const { isAuthenticated } = useAuth()
+
+  // UI State
+  const [isOpen, setIsOpen] = useState(false)
+  const [isMinimized, setIsMinimized] = useState(true)
+
+  // Session State
+  const [sessions, setSessions] = useState<ChatSession[]>([])
+  const [currentSession, setCurrentSession] = useState<ChatSession | null>(null)
+  const [isLoadingSessions, setIsLoadingSessions] = useState(false)
+
+  // Message State
+  const [messages, setMessages] = useState<ChatMessage[]>([])
+  const [isStreaming, setIsStreaming] = useState(false)
+  const [streamingContent, setStreamingContent] = useState('')
+
+  // Model State
+  const [models, setModels] = useState<LlmModel[]>([])
+  const [selectedModelId, setSelectedModelId] = useState<string | null>(null)
+
+  // Page Context
+  const [currentPageContext, setCurrentPageContext] = useState<PageContext | null>(null)
+
+  // Abort controller for streaming
+  const abortControllerRef = useRef<AbortController | null>(null)
+
+  // UI Actions
+  const toggleChat = useCallback(() => {
+    if (isMinimized) {
+      setIsMinimized(false)
+      setIsOpen(true)
+    } else {
+      setIsMinimized(true)
+    }
+  }, [isMinimized])
+
+  const minimizeChat = useCallback(() => {
+    setIsMinimized(true)
+  }, [])
+
+  const expandChat = useCallback(() => {
+    setIsMinimized(false)
+    setIsOpen(true)
+  }, [])
+
+  const closeChat = useCallback(() => {
+    setIsOpen(false)
+    setIsMinimized(true)
+  }, [])
+
+  // Session Actions
+  const refreshSessions = useCallback(async () => {
+    if (!isAuthenticated) return
+
+    setIsLoadingSessions(true)
+    try {
+      const response = await chatApi.listSessions(undefined, 50)
+      setSessions(response.sessions || [])
+    } catch (error) {
+      console.error('Failed to load sessions:', error)
+    } finally {
+      setIsLoadingSessions(false)
+    }
+  }, [isAuthenticated])
+
+  const createSession = useCallback(async (workspaceId?: string): Promise<ChatSession> => {
+    const session = await chatApi.createSession({
+      workspace_id: workspaceId,
+      model_id: selectedModelId || undefined,
+    })
+    setSessions(prev => [session, ...prev])
+    setCurrentSession(session)
+    setMessages([])
+    return session
+  }, [selectedModelId])
+
+  const switchSession = useCallback(async (sessionId: string) => {
+    try {
+      const session = await chatApi.getSession(sessionId)
+      setCurrentSession(session)
+      setMessages(session.messages || [])
+    } catch (error) {
+      console.error('Failed to switch session:', error)
+      throw error
+    }
+  }, [])
+
+  const deleteSession = useCallback(async (sessionId: string) => {
+    await chatApi.deleteSession(sessionId)
+    setSessions(prev => prev.filter(s => s.id !== sessionId))
+    if (currentSession?.id === sessionId) {
+      setCurrentSession(null)
+      setMessages([])
+    }
+  }, [currentSession?.id])
+
+  // Message Actions
+  const sendMessage = useCallback(async (content: string) => {
+    if (!currentSession) {
+      // Create a new session first
+      const session = await createSession()
+      setCurrentSession(session)
+    }
+
+    const sessionId = currentSession?.id
+    if (!sessionId) return
+
+    // Add user message optimistically
+    const userMessage: ChatMessage = {
+      id: `temp-${Date.now()}`,
+      role: 'user',
+      content,
+      created_at: Date.now() / 1000,
+      page_context: currentPageContext || undefined,
+    }
+    setMessages(prev => [...prev, userMessage])
+
+    // Start streaming
+    setIsStreaming(true)
+    setStreamingContent('')
+
+    try {
+      await chatApi.streamMessage(
+        sessionId,
+        {
+          content,
+          page_context: currentPageContext || undefined,
+        },
+        (chunk) => {
+          if (chunk.delta) {
+            setStreamingContent(prev => prev + chunk.delta)
+          }
+          if (chunk.finish_reason && chunk.message_id) {
+            // Stream complete, add the full message
+            const assistantMessage: ChatMessage = {
+              id: chunk.message_id,
+              role: 'assistant',
+              content: '', // Will be set below
+              created_at: Date.now() / 1000,
+            }
+            setMessages(prev => {
+              // Get current streaming content
+              const fullContent = prev.length > 0 ? streamingContent : ''
+              return [...prev, { ...assistantMessage, content: fullContent }]
+            })
+          }
+        },
+        (error) => {
+          console.error('Stream error:', error)
+        }
+      )
+
+      // After streaming completes, refresh the session to get proper message IDs
+      const updatedSession = await chatApi.getSession(sessionId)
+      setMessages(updatedSession.messages || [])
+      setCurrentSession(updatedSession)
+
+      // Update session in list
+      setSessions(prev => prev.map(s =>
+        s.id === sessionId ? { ...s, title: updatedSession.title, updated_at: updatedSession.updated_at } : s
+      ))
+    } catch (error) {
+      console.error('Failed to send message:', error)
+      // Remove optimistic user message on error
+      setMessages(prev => prev.filter(m => m.id !== userMessage.id))
+    } finally {
+      setIsStreaming(false)
+      setStreamingContent('')
+    }
+  }, [currentSession, currentPageContext, createSession, streamingContent])
+
+  const clearMessages = useCallback(async () => {
+    if (!currentSession) return
+
+    await chatApi.clearSessionMessages(currentSession.id)
+    setMessages([])
+  }, [currentSession])
+
+  // Model Actions
+  const loadModels = useCallback(async () => {
+    try {
+      const response = await chatApi.listModels()
+      setModels(response.models || [])
+      // Select first model if none selected
+      if (!selectedModelId && response.models?.length > 0) {
+        setSelectedModelId(response.models[0].id)
+      }
+    } catch (error) {
+      console.error('Failed to load models:', error)
+    }
+  }, [selectedModelId])
+
+  // Load sessions and models on mount when authenticated
+  useEffect(() => {
+    if (isAuthenticated) {
+      refreshSessions()
+      loadModels()
+    }
+  }, [isAuthenticated, refreshSessions, loadModels])
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      if (abortControllerRef.current) {
+        abortControllerRef.current.abort()
+      }
+    }
+  }, [])
+
+  const value: ChatContextValue = {
+    // UI State
+    isOpen,
+    isMinimized,
+    toggleChat,
+    minimizeChat,
+    expandChat,
+    closeChat,
+
+    // Session Management
+    sessions,
+    currentSession,
+    isLoadingSessions,
+    createSession,
+    switchSession,
+    deleteSession,
+    refreshSessions,
+
+    // Messages
+    messages,
+    isStreaming,
+    streamingContent,
+    sendMessage,
+    clearMessages,
+
+    // Models
+    models,
+    selectedModelId,
+    setSelectedModelId,
+    loadModels,
+
+    // Page Context
+    currentPageContext,
+    setCurrentPageContext,
+  }
+
+  return (
+    <ChatContext.Provider value={value}>
+      {children}
+    </ChatContext.Provider>
+  )
+}
+
+export function useChat(): ChatContextValue {
+  const context = useContext(ChatContext)
+  if (!context) {
+    throw new Error('useChat must be used within a ChatProvider')
+  }
+  return context
+}

+ 67 - 0
webui/src/hooks/usePageContext.ts

@@ -0,0 +1,67 @@
+// Hook to track the current page context for the LLM assistant
+
+import { useEffect, useMemo } from 'react'
+import { useLocation, useParams } from 'react-router-dom'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { useChat } from '@/contexts/ChatContext'
+import type { PageContext } from '@/types/chat'
+
+export function usePageContext() {
+  const location = useLocation()
+  const params = useParams()
+  const { currentWorkspace } = useWorkspace()
+  const { setCurrentPageContext } = useChat()
+
+  const pageContext: PageContext = useMemo(() => {
+    const path = location.pathname
+    let title = 'Dashboard'
+
+    // Determine page title based on path
+    if (path === '/dashboard' || path === '/') {
+      title = 'Dashboard'
+    } else if (path.includes('/workspaces')) {
+      title = 'Workspaces'
+    } else if (path.includes('/users')) {
+      title = 'Users'
+    } else if (path.includes('/groups')) {
+      title = 'Groups'
+    } else if (path.includes('/views')) {
+      if (params.viewId) {
+        title = `View: ${params.viewId}`
+      } else {
+        title = 'Views'
+      }
+    } else if (path.includes('/collections')) {
+      if (params.collection) {
+        title = `Collection: ${params.collection}`
+      } else {
+        title = 'Collections'
+      }
+    } else if (path.includes('/pages')) {
+      if (params.pageSlug) {
+        title = `Page: ${params.pageSlug}`
+      } else {
+        title = 'Pages'
+      }
+    } else if (path.includes('/settings')) {
+      title = 'Settings'
+    } else if (path.includes('/llm-providers')) {
+      title = 'LLM Providers'
+    }
+
+    return {
+      path,
+      title,
+      workspace_id: currentWorkspace?.id || params.workspaceId,
+      collection: params.collection,
+      view_id: params.viewId,
+    }
+  }, [location.pathname, params, currentWorkspace?.id])
+
+  // Update the chat context with the current page context
+  useEffect(() => {
+    setCurrentPageContext(pageContext)
+  }, [pageContext, setCurrentPageContext])
+
+  return pageContext
+}

+ 6 - 3
webui/src/main.tsx

@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
 import { AuthProvider } from '@/contexts/AuthContext'
 import { WorkspaceProvider } from '@/contexts/WorkspaceContext'
 import { WebSocketProvider } from '@/contexts/WebSocketContext'
+import { ChatProvider } from '@/contexts/ChatContext'
 import { ToastProvider } from '@/components/Toast'
 import App from './App'
 import './index.css'
@@ -14,9 +15,11 @@ createRoot(document.getElementById('root')!).render(
       <AuthProvider>
         <WorkspaceProvider>
           <WebSocketProvider>
-            <ToastProvider>
-              <App />
-            </ToastProvider>
+            <ChatProvider>
+              <ToastProvider>
+                <App />
+              </ToastProvider>
+            </ChatProvider>
           </WebSocketProvider>
         </WorkspaceProvider>
       </AuthProvider>

+ 440 - 0
webui/src/pages/LlmProviders.tsx

@@ -0,0 +1,440 @@
+// LLM Providers management page
+
+import { useState, useEffect, useCallback } from 'react'
+import { Plus, Trash2, Edit2, RefreshCw, CheckCircle, XCircle, Loader2 } from 'lucide-react'
+import * as chatApi from '@/api/chat'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+import type { LlmProvider, CreateProviderRequest, UpdateProviderRequest } from '@/types/chat'
+
+const PROVIDER_TYPES = [
+  { value: 'PROVIDER_TYPE_OPENAI', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
+  { value: 'PROVIDER_TYPE_ANTHROPIC', label: 'Anthropic Compatible', defaultUrl: 'https://api.anthropic.com/v1' },
+]
+
+interface ProviderFormData {
+  name: string
+  type: string
+  base_url: string
+  api_key: string
+  enabled: boolean
+}
+
+function ProviderModal({
+  provider,
+  onClose,
+  onSave,
+}: {
+  provider: LlmProvider | null
+  onClose: () => void
+  onSave: () => void
+}) {
+  const [formData, setFormData] = useState<ProviderFormData>(() => {
+    if (provider) {
+      return {
+        name: provider.name,
+        type: provider.type,
+        base_url: provider.base_url,
+        api_key: '',
+        enabled: provider.enabled,
+      }
+    }
+    return {
+      name: '',
+      type: 'PROVIDER_TYPE_OPENAI',
+      base_url: 'https://api.openai.com/v1',
+      api_key: '',
+      enabled: true,
+    }
+  })
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleTypeChange = (type: string) => {
+    const providerType = PROVIDER_TYPES.find(t => t.value === type)
+    setFormData({
+      ...formData,
+      type,
+      base_url: providerType?.defaultUrl || formData.base_url,
+    })
+  }
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      if (provider) {
+        const update: UpdateProviderRequest = {
+          name: formData.name,
+          type: formData.type,
+          base_url: formData.base_url,
+          enabled: formData.enabled,
+        }
+        if (formData.api_key) {
+          update.api_key = formData.api_key
+        }
+        await chatApi.adminUpdateProvider(provider.id, update)
+      } else {
+        const create: CreateProviderRequest = {
+          name: formData.name,
+          type: formData.type,
+          base_url: formData.base_url,
+          api_key: formData.api_key,
+          enabled: formData.enabled,
+        }
+        await chatApi.adminCreateProvider(create)
+      }
+      onSave()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save provider')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">
+          {provider ? 'Edit Provider' : 'Add Provider'}
+        </h2>
+
+        <form onSubmit={handleSubmit} className="mt-4 space-y-4">
+          {error && (
+            <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+          )}
+
+          <Input
+            label="Name"
+            name="name"
+            value={formData.name}
+            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+            placeholder="e.g., OpenAI Production"
+            required
+            autoFocus
+          />
+
+          <div>
+            <label className="block text-sm font-medium text-gray-700 mb-1">
+              Provider Type
+            </label>
+            <select
+              value={formData.type}
+              onChange={(e) => handleTypeChange(e.target.value)}
+              className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
+            >
+              {PROVIDER_TYPES.map((type) => (
+                <option key={type.value} value={type.value}>
+                  {type.label}
+                </option>
+              ))}
+            </select>
+          </div>
+
+          <Input
+            label="API Endpoint"
+            name="base_url"
+            value={formData.base_url}
+            onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
+            placeholder="https://api.openai.com/v1"
+            required
+          />
+
+          <Input
+            label={provider ? 'API Key (leave empty to keep current)' : 'API Key'}
+            name="api_key"
+            type="password"
+            value={formData.api_key}
+            onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
+            placeholder={provider ? '••••••••' : 'sk-...'}
+            required={!provider}
+          />
+
+          <div className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              id="enabled"
+              checked={formData.enabled}
+              onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
+              className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+            />
+            <label htmlFor="enabled" className="text-sm text-gray-700">
+              Enabled
+            </label>
+          </div>
+
+          <div className="flex justify-end gap-3 pt-4 border-t">
+            <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+              Cancel
+            </Button>
+            <Button type="submit" isLoading={isLoading}>
+              {provider ? 'Save Changes' : 'Add Provider'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+function DeleteConfirmModal({
+  provider,
+  onClose,
+  onConfirm,
+}: {
+  provider: LlmProvider
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleDelete = async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await chatApi.adminDeleteProvider(provider.id)
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete provider')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Delete Provider</h2>
+        <p className="mt-2 text-gray-600">
+          Are you sure you want to delete <strong>{provider.name}</strong>? This action cannot be undone.
+        </p>
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
+            Delete
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default function LlmProviders() {
+  const [providers, setProviders] = useState<LlmProvider[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [editingProvider, setEditingProvider] = useState<LlmProvider | null>(null)
+  const [deletingProvider, setDeletingProvider] = useState<LlmProvider | null>(null)
+  const [isModalOpen, setIsModalOpen] = useState(false)
+  const [refreshingProvider, setRefreshingProvider] = useState<string | null>(null)
+  const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; latency_ms: number } | null>>({})
+
+  const loadProviders = useCallback(async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const response = await chatApi.adminListProviders()
+      setProviders(response.providers || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to load providers')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [])
+
+  const checkHealth = useCallback(async (providerId: string) => {
+    try {
+      const health = await chatApi.getProviderHealth(providerId)
+      setHealthStatus(prev => ({
+        ...prev,
+        [providerId]: { healthy: health.healthy, latency_ms: health.latency_ms },
+      }))
+    } catch {
+      setHealthStatus(prev => ({
+        ...prev,
+        [providerId]: { healthy: false, latency_ms: 0 },
+      }))
+    }
+  }, [])
+
+  const refreshModels = useCallback(async (providerId: string) => {
+    setRefreshingProvider(providerId)
+    try {
+      await chatApi.refreshModels(providerId)
+      // Also refresh health
+      await checkHealth(providerId)
+    } catch (err) {
+      console.error('Failed to refresh models:', err)
+    } finally {
+      setRefreshingProvider(null)
+    }
+  }, [checkHealth])
+
+  useEffect(() => {
+    loadProviders()
+  }, [loadProviders])
+
+  // Check health for all providers on load
+  useEffect(() => {
+    providers.forEach(p => {
+      if (p.enabled) {
+        checkHealth(p.id)
+      }
+    })
+  }, [providers, checkHealth])
+
+  const handleModalClose = () => {
+    setIsModalOpen(false)
+    setEditingProvider(null)
+  }
+
+  const handleModalSave = () => {
+    handleModalClose()
+    loadProviders()
+  }
+
+  const handleDeleteClose = () => {
+    setDeletingProvider(null)
+  }
+
+  const handleDeleteConfirm = () => {
+    handleDeleteClose()
+    loadProviders()
+  }
+
+  const getProviderTypeLabel = (type: string) => {
+    const providerType = PROVIDER_TYPES.find(t => t.value === type)
+    return providerType?.label || type
+  }
+
+  return (
+    <div className="mx-auto max-w-4xl">
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">LLM Providers</h1>
+          <p className="mt-1 text-sm text-gray-500">
+            Configure AI model providers for the chat assistant
+          </p>
+        </div>
+        <Button onClick={() => setIsModalOpen(true)}>
+          <Plus className="mr-2 h-4 w-4" />
+          Add Provider
+        </Button>
+      </div>
+
+      {error && (
+        <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+      )}
+
+      {isLoading ? (
+        <div className="mt-8 flex justify-center">
+          <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
+        </div>
+      ) : providers.length === 0 ? (
+        <div className="mt-8 text-center">
+          <p className="text-gray-500">No providers configured yet.</p>
+          <Button onClick={() => setIsModalOpen(true)} className="mt-4">
+            <Plus className="mr-2 h-4 w-4" />
+            Add your first provider
+          </Button>
+        </div>
+      ) : (
+        <div className="mt-6 space-y-4">
+          {providers.map((provider) => (
+            <div
+              key={provider.id}
+              className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
+            >
+              <div className="flex items-start justify-between">
+                <div className="flex-1">
+                  <div className="flex items-center gap-2">
+                    <h3 className="font-medium text-gray-900">{provider.name}</h3>
+                    {!provider.enabled && (
+                      <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
+                        Disabled
+                      </span>
+                    )}
+                    {provider.enabled && healthStatus[provider.id] !== undefined && (
+                      healthStatus[provider.id]?.healthy ? (
+                        <span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
+                          <CheckCircle className="h-3 w-3" />
+                          {healthStatus[provider.id]?.latency_ms}ms
+                        </span>
+                      ) : (
+                        <span className="flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700">
+                          <XCircle className="h-3 w-3" />
+                          Unhealthy
+                        </span>
+                      )
+                    )}
+                  </div>
+                  <p className="mt-1 text-sm text-gray-500">{getProviderTypeLabel(provider.type)}</p>
+                  <p className="mt-0.5 text-xs text-gray-400">{provider.base_url}</p>
+                  {provider.has_api_key && (
+                    <p className="mt-1 text-xs text-green-600">API key configured</p>
+                  )}
+                </div>
+
+                <div className="flex items-center gap-2">
+                  <button
+                    onClick={() => refreshModels(provider.id)}
+                    disabled={refreshingProvider === provider.id}
+                    className="rounded p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50"
+                    title="Refresh models"
+                  >
+                    <RefreshCw className={`h-4 w-4 ${refreshingProvider === provider.id ? 'animate-spin' : ''}`} />
+                  </button>
+                  <button
+                    onClick={() => {
+                      setEditingProvider(provider)
+                      setIsModalOpen(true)
+                    }}
+                    className="rounded p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+                    title="Edit provider"
+                  >
+                    <Edit2 className="h-4 w-4" />
+                  </button>
+                  <button
+                    onClick={() => setDeletingProvider(provider)}
+                    className="rounded p-2 text-gray-400 hover:bg-red-50 hover:text-red-600"
+                    title="Delete provider"
+                  >
+                    <Trash2 className="h-4 w-4" />
+                  </button>
+                </div>
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {isModalOpen && (
+        <ProviderModal
+          provider={editingProvider}
+          onClose={handleModalClose}
+          onSave={handleModalSave}
+        />
+      )}
+
+      {deletingProvider && (
+        <DeleteConfirmModal
+          provider={deletingProvider}
+          onClose={handleDeleteClose}
+          onConfirm={handleDeleteConfirm}
+        />
+      )}
+    </div>
+  )
+}

+ 113 - 0
webui/src/types/chat.ts

@@ -0,0 +1,113 @@
+// Chat types for the LLM assistant
+
+export interface PageContext {
+  path: string
+  title: string
+  workspace_id?: string
+  collection?: string
+  view_id?: string
+}
+
+export interface ChatMessage {
+  id: string
+  role: 'user' | 'assistant' | 'system' | 'tool'
+  content: string
+  created_at: number
+  page_context?: PageContext
+  tool_calls?: ToolCall[]
+  tool_result?: string
+}
+
+export interface ToolCall {
+  id: string
+  name: string
+  arguments: string
+}
+
+export interface ChatSession {
+  id: string
+  user_id: string
+  workspace_id: string
+  title: string
+  model_id: string
+  system_prompt: string
+  messages: ChatMessage[]
+  message_count?: number
+  created_at: number
+  updated_at: number
+}
+
+export interface ChatUsage {
+  prompt_tokens: number
+  completion_tokens: number
+  total_tokens: number
+}
+
+export interface ChatResponse {
+  session_id: string
+  message_id: string
+  content: string
+  finish_reason: string
+  tool_calls?: ToolCall[]
+  usage: ChatUsage
+}
+
+export interface ChatStreamChunk {
+  session_id: string
+  delta?: string
+  finish_reason?: string
+  message_id?: string
+}
+
+export interface LlmProvider {
+  id: string
+  name: string
+  type: string
+  base_url: string
+  enabled: boolean
+  healthy?: boolean
+  has_api_key?: boolean
+}
+
+export interface LlmModel {
+  id: string
+  name: string
+  provider_id: string
+  provider_type: string
+  context_length: number
+  supports_tools: boolean
+  supports_vision: boolean
+}
+
+export interface CreateSessionRequest {
+  workspace_id?: string
+  title?: string
+  model_id?: string
+  system_prompt?: string
+}
+
+export interface SendMessageRequest {
+  content: string
+  page_context?: PageContext
+}
+
+export interface CreateProviderRequest {
+  name: string
+  type: string
+  base_url?: string
+  api_key?: string
+  enabled?: boolean
+}
+
+export interface UpdateProviderRequest {
+  name?: string
+  type?: string
+  base_url?: string
+  api_key?: string
+  enabled?: boolean
+}
+
+export interface SetWorkspaceKeyRequest {
+  provider_id: string
+  api_key: string
+}

+ 3 - 0
webui/src/types/index.ts

@@ -325,3 +325,6 @@ export interface TextBlockConfig {
 export interface SpacerConfig {
   size: 'sm' | 'md' | 'lg' | 'xl'
 }
+
+// Re-export chat types
+export * from './chat'