From 74b8d43032fde207bf1b142f94a8f33fd2af2052 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Fri, 1 May 2026 23:30:34 -0400 Subject: [PATCH] refactor!: move LLM configuration from in-app settings to CLI/env vars - Remove `api_endpoint` from Settings model and settings UI - Add `--llm-endpoint` / `AETHERA_LLM_ENDPOINT` and `--llm-key` / `AETHERA_LLM_KEY` CLI flags (endpoint is required) - Update client constructor to accept API key parameter - Update tests and documentation to reflect new configuration approach BREAKING CHANGE: LLM endpoint and key must now be provided via `AETHERA_LLM_ENDPOINT` and `AETHERA_LLM_KEY` environment variables or CLI flags instead of the Settings page. --- Dockerfile | 3 +++ README.md | 18 ++++++++----- backend/cmd/config.go | 7 +++++ backend/cmd/main.go | 6 ++++- backend/internal/api/handlers.go | 37 ++++++-------------------- backend/internal/client/client.go | 8 ++++-- backend/internal/client/client_test.go | 6 ++--- backend/internal/server/server.go | 4 +-- backend/internal/store/memory_test.go | 4 +-- backend/internal/store/storage.go | 1 - backend/internal/store/storage_test.go | 4 +-- frontend/public/pages/settings.html | 19 ------------- frontend/src/types/index.ts | 1 - 13 files changed, 47 insertions(+), 71 deletions(-) diff --git a/Dockerfile b/Dockerfile index 56dac25..9277e01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,7 @@ EXPOSE 8080 ENV AETHERA_LISTEN=0.0.0.0 ENV AETHERA_PORT=8080 ENV AETHERA_DATA_DIR=/app/data +# LLM Configuration (required) +# ENV AETHERA_LLM_ENDPOINT=https://api.example.com/v1 +# ENV AETHERA_LLM_KEY=your-api-key-here ENTRYPOINT ["./aethera"] diff --git a/README.md b/README.md index 70c5e18..cdd9a44 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,10 @@ make all # Build frontend + backend ```bash make docker -docker run -p 8080:8080 -v aethera-data:/app/data aethera +docker run -p 8080:8080 \ + -e AETHERA_LLM_ENDPOINT=https://api.example.com/v1 \ + -e AETHERA_LLM_KEY=your-key \ + -v aethera-data:/app/data aethera ``` ### Manual Build @@ -58,7 +61,9 @@ Configuration is available via CLI flags and environment variables (prefixed `AE | Flag | Env Var | Default | Description | |----------------|---------------------|-------------|--------------------------------------------| -| `--data-dir` | `AETHERA_DATA_DIR` | `./data` | Directory for chats, settings, and images | +| `--llm-endpoint` | `AETHERA_LLM_ENDPOINT` | *(required)* | OpenAI-compatible API endpoint URL | +| `--llm-key` | `AETHERA_LLM_KEY` | | API key for authentication | +| `--data-dir` | `AETHERA_DATA_DIR` | `./data` | Directory for chats, settings, and images | | `--static-dir` | `AETHERA_STATIC_DIR`| *(embedded)*| Serve frontend from disk (for development) | | `--listen` | `AETHERA_LISTEN` | `localhost` | Listen address | | `--port` | `AETHERA_PORT` | `8080` | Listen port | @@ -66,7 +71,7 @@ Configuration is available via CLI flags and environment variables (prefixed `AE Example: ```bash -./backend/dist/aethera --port 3000 --listen 0.0.0.0 +AETHERA_LLM_ENDPOINT=https://api.example.com/v1 AETHERA_LLM_KEY=your-key ./backend/dist/aethera ``` ## Development @@ -89,10 +94,9 @@ This starts the Go backend (serving frontend from disk) and the frontend in watc ## Getting Started -1. **Configure Your API** — navigate to Settings and enter your OpenAI-compatible API endpoint URL -2. **Start Chatting** — use the Chat interface to begin conversations -3. **Generate Images** — visit the Images page to create or edit images -4. **Manage Content** — view, delete, and organize conversations and images +1. **Configure Your API** — set `AETHERA_LLM_ENDPOINT` and optionally `AETHERA_LLM_KEY` environment variables +2. **Start the Server** — run the binary and navigate to `http://localhost:8080` +3. **Configure Model Selectors** — navigate to Settings to configure model selectors for chat and image generation ## Supported AI Services diff --git a/backend/cmd/config.go b/backend/cmd/config.go index c3dfcc5..65eba92 100644 --- a/backend/cmd/config.go +++ b/backend/cmd/config.go @@ -15,6 +15,8 @@ type cliParams struct { DataDir string StaticDir string SettingsFile string + LLMEndpoint string + LLMKey string } // getEnvOrDefault returns the value of an environment variable or a default value @@ -36,6 +38,11 @@ func getEnvIntOrDefault(key string, defaultValue int) int { } func (p *cliParams) Validate() error { + // Require LLM Configuration + if p.LLMEndpoint == "" { + return fmt.Errorf("LLM endpoint is required (set AETHERA_LLM_ENDPOINT)") + } + // Ensure Generated Directories imgDir := path.Join(p.DataDir, "generated/images") if err := os.MkdirAll(imgDir, 0755); err != nil { diff --git a/backend/cmd/main.go b/backend/cmd/main.go index f782a23..390c767 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -17,6 +17,8 @@ var ( ListenPort: getEnvIntOrDefault("PORT", 8080), DataDir: getEnvOrDefault("DATA_DIR", "./data"), StaticDir: getEnvOrDefault("STATIC_DIR", ""), + LLMEndpoint: getEnvOrDefault("LLM_ENDPOINT", ""), + LLMKey: getEnvOrDefault("LLM_KEY", ""), } rootCmd = &cobra.Command{Use: "aethera"} ) @@ -26,6 +28,8 @@ func init() { rootCmd.PersistentFlags().StringVar(¶ms.StaticDir, "static-dir", params.StaticDir, "Directory to serve static frontend files from instead of embedded assets (env: AETHERA_STATIC_DIR)") rootCmd.PersistentFlags().StringVar(¶ms.ListenAddr, "listen", params.ListenAddr, "Address to listen on (env: AETHERA_LISTEN)") rootCmd.PersistentFlags().IntVar(¶ms.ListenPort, "port", params.ListenPort, "Port to listen on (env: AETHERA_PORT)") + rootCmd.PersistentFlags().StringVar(¶ms.LLMEndpoint, "llm-endpoint", params.LLMEndpoint, "LLM API endpoint URL (env: AETHERA_LLM_ENDPOINT)") + rootCmd.PersistentFlags().StringVar(¶ms.LLMKey, "llm-key", params.LLMKey, "LLM API key (env: AETHERA_LLM_KEY)") } func main() { @@ -42,7 +46,7 @@ func main() { // Start Server rootCmd.Run = func(cmd *cobra.Command, args []string) { - server.StartServer(fileStore, params.DataDir, params.StaticDir, params.ListenAddr, params.ListenPort) + server.StartServer(fileStore, params.DataDir, params.StaticDir, params.ListenAddr, params.ListenPort, params.LLMEndpoint, params.LLMKey) } if err := rootCmd.Execute(); err != nil { diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index cea01e4..23bd0f6 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -28,14 +28,18 @@ type API struct { store store.Store client *client.Client dataDir string + llmEndpoint string + llmKey string generationManager *generationManager } -func New(s store.Store, dataDir string, logger *logrus.Logger) *API { +func New(s store.Store, dataDir string, logger *logrus.Logger, llmEndpoint, llmKey string) *API { return &API{ store: s, dataDir: dataDir, logger: logger.WithField("service", "api"), + llmEndpoint: llmEndpoint, + llmKey: llmKey, generationManager: newGenerationManager(), } } @@ -68,24 +72,6 @@ func (a *API) PostSettings(w http.ResponseWriter, r *http.Request) { return } - if apiEndpoint := newSettings.APIEndpoint; apiEndpoint != "" { - baseURL, err := url.Parse(apiEndpoint) - if err != nil { - errMsg := fmt.Sprintf("Invalid API Endpoint URL: %q", baseURL) - log.WithError(err).Error(errMsg) - http.Error(w, errMsg, http.StatusBadRequest) - return - } - - testClient := client.NewClient(baseURL) - if _, err := testClient.GetModels(r.Context()); err != nil { - log.WithError(err).Error("failed to access configured API endpoint") - http.Error(w, "API endpoint inaccessible", http.StatusBadRequest) - return - } - a.client = nil - } - if err := a.store.SaveSettings(&newSettings); err != nil { log.WithError(err).Error("failed to save settings") http.Error(w, "Failed to save application settings", http.StatusInternalServerError) @@ -516,20 +502,13 @@ func (a *API) getClient() (*client.Client, error) { return a.client, nil } - // Get Settings & Validate Endpoint - settings, err := a.store.GetSettings() - if err != nil { - return nil, fmt.Errorf("failed to retrieve application settings: %w", err) - } else if settings.APIEndpoint == "" { - return nil, errors.New("no API endpoint configured in settings") - } - - baseURL, err := url.Parse(settings.APIEndpoint) + // Parse LLM Endpoint from Config + baseURL, err := url.Parse(a.llmEndpoint) if err != nil { return nil, fmt.Errorf("invalid API endpoint URL: %w", err) } - a.client = client.NewClient(baseURL) + a.client = client.NewClient(baseURL, a.llmKey) return a.client, nil } diff --git a/backend/internal/client/client.go b/backend/internal/client/client.go index e10726e..d4a65eb 100644 --- a/backend/internal/client/client.go +++ b/backend/internal/client/client.go @@ -288,8 +288,12 @@ func populateUsageTimings(msgStats *types.MessageStats, usage openai.CompletionU return didChange } -func NewClient(baseURL *url.URL) *Client { - oaiClient := openai.NewClient(option.WithBaseURL(baseURL.String())) +func NewClient(baseURL *url.URL, apiKey string) *Client { + opts := []option.RequestOption{option.WithBaseURL(baseURL.String())} + if apiKey != "" { + opts = append(opts, option.WithAPIKey(apiKey)) + } + oaiClient := openai.NewClient(opts...) return &Client{oaiClient: &oaiClient} } diff --git a/backend/internal/client/client_test.go b/backend/internal/client/client_test.go index e79cc9c..7c38e7d 100644 --- a/backend/internal/client/client_test.go +++ b/backend/internal/client/client_test.go @@ -20,7 +20,7 @@ func TestSendMessage(t *testing.T) { if err != nil { t.Fatalf("Failed to parse base URL: %v", err) } - client := NewClient(baseURL) + client := NewClient(baseURL, os.Getenv("AETHERA_LLM_KEY")) // Create Context ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) @@ -69,7 +69,7 @@ func TestSummarizeChat(t *testing.T) { if err != nil { t.Fatalf("Failed to parse base URL: %v", err) } - client := NewClient(baseURL) + client := NewClient(baseURL, os.Getenv("AETHERA_LLM_KEY")) // Create Context ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -96,7 +96,7 @@ func TestSendMessageWithImage(t *testing.T) { if err != nil { t.Fatalf("Failed to parse base URL: %v", err) } - client := NewClient(baseURL) + client := NewClient(baseURL, os.Getenv("AETHERA_LLM_KEY")) // Create Context ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 3f2573d..36d93f9 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -13,12 +13,12 @@ import ( "reichard.io/aethera/web" ) -func StartServer(settingsStore store.Store, dataDir, staticDir, listenAddress string, listenPort int) { +func StartServer(settingsStore store.Store, dataDir, staticDir, listenAddress string, listenPort int, llmEndpoint, llmKey string) { mux := http.NewServeMux() // Create API Instance - use settingsStore as the unified store for both settings and chat logger := logrus.New() - api := api.New(settingsStore, dataDir, logger) + api := api.New(settingsStore, dataDir, logger, llmEndpoint, llmKey) // Serve Static Assets if staticDir != "" { diff --git a/backend/internal/store/memory_test.go b/backend/internal/store/memory_test.go index a6d9b7e..aa4c04c 100644 --- a/backend/internal/store/memory_test.go +++ b/backend/internal/store/memory_test.go @@ -141,7 +141,6 @@ func TestInMemoryStore_SaveSettings(t *testing.T) { store := NewInMemoryStore() settings := &Settings{ - APIEndpoint: "http://example.com", ImageEditSelector: ".image-edit", ImageGenerationSelector: ".image-gen", TextGenerationSelector: ".text-gen", @@ -161,7 +160,6 @@ func TestInMemoryStore_GetSettings(t *testing.T) { // Set some settings settings = &Settings{ - APIEndpoint: "http://example.com", ImageEditSelector: ".image-edit", ImageGenerationSelector: ".image-gen", TextGenerationSelector: ".text-gen", @@ -172,5 +170,5 @@ func TestInMemoryStore_GetSettings(t *testing.T) { // Get the settings settings, err = store.GetSettings() require.NoError(t, err) - assert.Equal(t, "http://example.com", settings.APIEndpoint) + assert.Equal(t, ".image-edit", settings.ImageEditSelector) } diff --git a/backend/internal/store/storage.go b/backend/internal/store/storage.go index bef2e5e..dedf50b 100644 --- a/backend/internal/store/storage.go +++ b/backend/internal/store/storage.go @@ -15,7 +15,6 @@ var _ Store = (*FileStore)(nil) // Settings represents the application settings type Settings struct { - APIEndpoint string `json:"api_endpoint,omitempty"` ImageEditSelector string `json:"image_edit_selector,omitempty"` ImageGenerationSelector string `json:"image_generation_selector,omitempty"` TextGenerationSelector string `json:"text_generation_selector,omitempty"` diff --git a/backend/internal/store/storage_test.go b/backend/internal/store/storage_test.go index 3b5eee8..3d62818 100644 --- a/backend/internal/store/storage_test.go +++ b/backend/internal/store/storage_test.go @@ -209,7 +209,6 @@ func TestFileStore_SaveSettings(t *testing.T) { require.NoError(t, err) settings := &Settings{ - APIEndpoint: "http://example.com", ImageEditSelector: ".image-edit", ImageGenerationSelector: ".image-gen", TextGenerationSelector: ".text-gen", @@ -237,7 +236,6 @@ func TestFileStore_GetSettings(t *testing.T) { // Set some settings settings = &Settings{ - APIEndpoint: "http://example.com", ImageEditSelector: ".image-edit", ImageGenerationSelector: ".image-gen", TextGenerationSelector: ".text-gen", @@ -248,5 +246,5 @@ func TestFileStore_GetSettings(t *testing.T) { // Get the settings settings, err = store.GetSettings() require.NoError(t, err) - assert.Equal(t, "http://example.com", settings.APIEndpoint) + assert.Equal(t, ".image-edit", settings.ImageEditSelector) } diff --git a/frontend/public/pages/settings.html b/frontend/public/pages/settings.html index 8cc5e12..73f6d14 100644 --- a/frontend/public/pages/settings.html +++ b/frontend/public/pages/settings.html @@ -3,25 +3,6 @@ @submit.prevent="saveSettings" class="p-0.5 w-full flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl" > -
- -
- -

URL of your API endpoint

-
-
Selectors