diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000000..00ea4e4ccb --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,27 @@ +# Environment variables for Browser Operator DevTools +# Copy this file to .env and set your API keys + +# OpenAI API Key (for GPT models) +OPENAI_API_KEY=sk-your-openai-api-key-here + +# OpenRouter API Key (for multiple LLM providers) +OPENROUTER_API_KEY=sk-or-your-openrouter-api-key-here + +# Groq API Key (for fast inference) +GROQ_API_KEY=gsk_your-groq-api-key-here + +# LiteLLM API Key (for self-hosted LLM proxy) +LITELLM_API_KEY=your-litellm-api-key-here + +# Usage: +# 1. Copy this file: cp .env.example .env +# 2. Set your API keys in the .env file +# 3. Build and run: docker-compose up --build +# +# The API keys will be embedded into the Docker image during build time +# and used as fallbacks when localStorage is empty. +# +# Priority order: +# 1. localStorage (user settings in DevTools) +# 2. Environment variables (this .env file) +# 3. Empty (no API key configured) \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 41f08a1b46..c2fbd32584 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,10 @@ # Multi-stage build for Chrome DevTools Frontend FROM --platform=linux/amd64 ubuntu:22.04 AS builder +# BuildKit is required for secret mounting +# Secrets are mounted at build time but not stored in layers +# Usage: DOCKER_BUILDKIT=1 docker-compose build + # Install required packages RUN apt-get update && apt-get install -y \ curl \ @@ -47,7 +51,8 @@ RUN git remote add upstream https://github.com/BrowserOperator/browser-operator- RUN git fetch upstream RUN git checkout upstream/main -# Build Browser Operator version +# Build Browser Operator version (using upstream code to avoid TypeScript compilation issues) +# Runtime API key injection will be handled by the entrypoint script RUN npm run build # Production stage @@ -59,4 +64,11 @@ COPY --from=builder /workspace/devtools/devtools-frontend/out/Default/gen/front_ # Copy nginx config COPY docker/nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 8000 \ No newline at end of file +# Copy and setup entrypoint script for runtime configuration +COPY docker/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +EXPOSE 8000 + +# Use entrypoint to generate runtime config and start nginx +ENTRYPOINT ["/docker-entrypoint.sh"] \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 55a144350f..efea122ad2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,13 +10,20 @@ services: ports: - "8000:8000" restart: unless-stopped - volumes: + # volumes: # For development: mount the built files directly (optional) - # Uncomment the line below to use local files instead of container files - # - ../out/Default/gen/front_end:/usr/share/nginx/html:ro + # Uncomment the lines below to use local files instead of container files + # volumes: + # - ../out/Default/gen/front_end:/usr/share/nginx/html:ro environment: - NGINX_HOST=localhost - NGINX_PORT=8000 + # Runtime API key injection - keys are passed to container at runtime + # These are injected into the DevTools frontend via runtime-config.js + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - GROQ_API_KEY=${GROQ_API_KEY:-} + - LITELLM_API_KEY=${LITELLM_API_KEY:-} healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000/"] interval: 30s diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000000..20adf0674f --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Docker entrypoint script for Browser Operator DevTools +# This script generates runtime configuration from environment variables +# and starts nginx to serve the DevTools frontend + +set -e + +# Log configuration status to container logs +echo "DevTools runtime configuration generated:" +[ -n "${OPENAI_API_KEY}" ] && echo " ✓ OPENAI_API_KEY configured" || echo " ✗ OPENAI_API_KEY not set" +[ -n "${OPENROUTER_API_KEY}" ] && echo " ✓ OPENROUTER_API_KEY configured" || echo " ✗ OPENROUTER_API_KEY not set" +[ -n "${GROQ_API_KEY}" ] && echo " ✓ GROQ_API_KEY configured" || echo " ✗ GROQ_API_KEY not set" +[ -n "${LITELLM_API_KEY}" ] && echo " ✓ LITELLM_API_KEY configured" || echo " ✗ LITELLM_API_KEY not set" + +# Inject API keys directly into DevTools JavaScript files +echo "🔧 Injecting API keys directly into JavaScript files..." + +# Find and modify the main DevTools entry point files +for js_file in /usr/share/nginx/html/entrypoints/*/devtools_app.js /usr/share/nginx/html/entrypoints/*/inspector_main.js; do + if [ -f "$js_file" ]; then + echo " ✓ Injecting into $(basename "$js_file")" + # Inject a global configuration object at the beginning of the file + sed -i '1i\ +// Runtime API key injection\ +window.__RUNTIME_CONFIG__ = {\ + OPENAI_API_KEY: "'"${OPENAI_API_KEY:-}"'",\ + OPENROUTER_API_KEY: "'"${OPENROUTER_API_KEY:-}"'",\ + GROQ_API_KEY: "'"${GROQ_API_KEY:-}"'",\ + LITELLM_API_KEY: "'"${LITELLM_API_KEY:-}"'",\ + timestamp: "'"$(date -Iseconds)"'",\ + source: "docker-runtime"\ +};\ +// Auto-save to localStorage\ +if (window.__RUNTIME_CONFIG__.OPENAI_API_KEY) localStorage.setItem("ai_chat_api_key", window.__RUNTIME_CONFIG__.OPENAI_API_KEY);\ +if (window.__RUNTIME_CONFIG__.OPENROUTER_API_KEY) localStorage.setItem("ai_chat_openrouter_api_key", window.__RUNTIME_CONFIG__.OPENROUTER_API_KEY);\ +if (window.__RUNTIME_CONFIG__.GROQ_API_KEY) localStorage.setItem("ai_chat_groq_api_key", window.__RUNTIME_CONFIG__.GROQ_API_KEY);\ +if (window.__RUNTIME_CONFIG__.LITELLM_API_KEY) localStorage.setItem("ai_chat_litellm_api_key", window.__RUNTIME_CONFIG__.LITELLM_API_KEY);\ +console.log("[RUNTIME-CONFIG] API keys injected directly into JavaScript:", {hasOpenAI: !!window.__RUNTIME_CONFIG__.OPENAI_API_KEY, hasOpenRouter: !!window.__RUNTIME_CONFIG__.OPENROUTER_API_KEY, hasGroq: !!window.__RUNTIME_CONFIG__.GROQ_API_KEY, hasLiteLLM: !!window.__RUNTIME_CONFIG__.LITELLM_API_KEY});\ +' "$js_file" + fi +done + +# Also inject into AI Chat panel files specifically +for js_file in /usr/share/nginx/html/panels/ai_chat/*.js; do + if [ -f "$js_file" ]; then + echo " ✓ Injecting into AI Chat panel $(basename "$js_file")" + sed -i '1i\ +// Runtime API key injection for AI Chat\ +if (typeof window !== "undefined" && !window.__RUNTIME_CONFIG__) {\ + window.__RUNTIME_CONFIG__ = {\ + OPENAI_API_KEY: "'"${OPENAI_API_KEY:-}"'",\ + OPENROUTER_API_KEY: "'"${OPENROUTER_API_KEY:-}"'",\ + GROQ_API_KEY: "'"${GROQ_API_KEY:-}"'",\ + LITELLM_API_KEY: "'"${LITELLM_API_KEY:-}"'",\ + timestamp: "'"$(date -Iseconds)"'",\ + source: "docker-runtime"\ + };\ +}\ +' "$js_file" + fi +done + +# Start nginx in foreground +echo "Starting nginx..." +exec nginx -g 'daemon off;' \ No newline at end of file diff --git a/front_end/entrypoints/devtools_app/devtools_app.ts b/front_end/entrypoints/devtools_app/devtools_app.ts index d989aa0326..ebc5c90df5 100644 --- a/front_end/entrypoints/devtools_app/devtools_app.ts +++ b/front_end/entrypoints/devtools_app/devtools_app.ts @@ -32,6 +32,9 @@ import '../../panels/ai_chat/ai_chat-meta.js'; import * as Root from '../../core/root/root.js'; import * as Main from '../main/main.js'; +// Runtime config is loaded via HTML script injection (docker-entrypoint.sh) +// No need for dynamic loading since it's already in the HTML head + // @ts-expect-error Exposed for legacy layout tests self.runtime = Root.Runtime.Runtime.instance({forceNew: true}); new Main.MainImpl.MainImpl(); diff --git a/front_end/panels/ai_chat/BUILD.gn b/front_end/panels/ai_chat/BUILD.gn index 0ae6cecf55..032f78f0a1 100644 --- a/front_end/panels/ai_chat/BUILD.gn +++ b/front_end/panels/ai_chat/BUILD.gn @@ -28,6 +28,7 @@ devtools_module("ai_chat") { "core/Types.ts", "core/AgentService.ts", "core/Constants.ts", + "core/EnvironmentConfig.ts", "core/GraphConfigs.ts", "core/ConfigurableGraph.ts", "core/BaseOrchestratorAgent.ts", @@ -133,6 +134,7 @@ _ai_chat_sources = [ "core/Types.ts", "core/AgentService.ts", "core/Constants.ts", + "core/EnvironmentConfig.ts", "core/GraphConfigs.ts", "core/ConfigurableGraph.ts", "core/BaseOrchestratorAgent.ts", diff --git a/front_end/panels/ai_chat/LLM/GroqProvider.ts b/front_end/panels/ai_chat/LLM/GroqProvider.ts index e5e6aaaaf0..410ea2f36d 100644 --- a/front_end/panels/ai_chat/LLM/GroqProvider.ts +++ b/front_end/panels/ai_chat/LLM/GroqProvider.ts @@ -7,6 +7,7 @@ import { LLMBaseProvider } from './LLMProvider.js'; import { LLMRetryManager } from './LLMErrorHandler.js'; import { LLMResponseParser } from './LLMResponseParser.js'; import { createLogger } from '../core/Logger.js'; +import { getEnvironmentConfig } from '../core/EnvironmentConfig.js'; const logger = createLogger('GroqProvider'); @@ -38,10 +39,29 @@ export class GroqProvider extends LLMBaseProvider { readonly name: LLMProvider = 'groq'; + private readonly envConfig = getEnvironmentConfig(); + constructor(private readonly apiKey: string) { super(); } + /** + * Get the API key with fallback hierarchy: + * 1. Constructor parameter (for backward compatibility) + * 2. localStorage (user-configured) + * 3. Build-time environment config + * 4. Empty string + */ + private getApiKey(): string { + // Constructor parameter (highest priority for backward compatibility) + if (this.apiKey && this.apiKey.trim() !== '') { + return this.apiKey.trim(); + } + + // Use environment config which handles localStorage -> build-time -> empty fallback + return this.envConfig.getApiKey('groq'); + } + /** * Get the chat completions endpoint URL */ @@ -92,7 +112,7 @@ export class GroqProvider extends LLMBaseProvider { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': `Bearer ${this.getApiKey()}`, }, body: JSON.stringify(payloadBody), }); @@ -259,7 +279,7 @@ export class GroqProvider extends LLMBaseProvider { const response = await fetch(this.getModelsEndpoint(), { method: 'GET', headers: { - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': `Bearer ${this.getApiKey()}`, }, }); @@ -432,21 +452,7 @@ export class GroqProvider extends LLMBaseProvider { * Validate that required credentials are available for Groq */ validateCredentials(): {isValid: boolean, message: string, missingItems?: string[]} { - const storageKeys = this.getCredentialStorageKeys(); - const apiKey = localStorage.getItem(storageKeys.apiKey!); - - if (!apiKey) { - return { - isValid: false, - message: 'Groq API key is required. Please add your API key in Settings.', - missingItems: ['API Key'] - }; - } - - return { - isValid: true, - message: 'Groq credentials are configured correctly.' - }; + return this.envConfig.validateCredentials('groq'); } /** @@ -454,7 +460,7 @@ export class GroqProvider extends LLMBaseProvider { */ getCredentialStorageKeys(): {apiKey: string} { return { - apiKey: 'ai_chat_groq_api_key' + apiKey: this.envConfig.getStorageKey('groq') }; } } \ No newline at end of file diff --git a/front_end/panels/ai_chat/LLM/OpenAIProvider.ts b/front_end/panels/ai_chat/LLM/OpenAIProvider.ts index b8c30ad323..63077a3ee0 100644 --- a/front_end/panels/ai_chat/LLM/OpenAIProvider.ts +++ b/front_end/panels/ai_chat/LLM/OpenAIProvider.ts @@ -7,6 +7,7 @@ import { LLMBaseProvider } from './LLMProvider.js'; import { LLMRetryManager } from './LLMErrorHandler.js'; import { LLMResponseParser } from './LLMResponseParser.js'; import { createLogger } from '../core/Logger.js'; +import { getEnvironmentConfig } from '../core/EnvironmentConfig.js'; const logger = createLogger('OpenAIProvider'); @@ -42,10 +43,29 @@ export class OpenAIProvider extends LLMBaseProvider { readonly name: LLMProvider = 'openai'; + private readonly envConfig = getEnvironmentConfig(); + constructor(private readonly apiKey: string) { super(); } + /** + * Get the API key with fallback hierarchy: + * 1. Constructor parameter (for backward compatibility) + * 2. localStorage (user-configured) + * 3. Build-time environment config + * 4. Empty string + */ + private getApiKey(): string { + // Constructor parameter (highest priority for backward compatibility) + if (this.apiKey && this.apiKey.trim() !== '') { + return this.apiKey.trim(); + } + + // Use environment config which handles localStorage -> build-time -> empty fallback + return this.envConfig.getApiKey('openai'); + } + /** * Determines the model family based on the model name */ @@ -280,7 +300,7 @@ export class OpenAIProvider extends LLMBaseProvider { metadata: { provider: 'openai', errorType: 'api_error', - hasApiKey: !!this.apiKey + hasApiKey: !!this.getApiKey() } }, context.traceId); } @@ -299,7 +319,7 @@ export class OpenAIProvider extends LLMBaseProvider { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, + Authorization: `Bearer ${this.getApiKey()}`, }, body: JSON.stringify(payloadBody), }); diff --git a/front_end/panels/ai_chat/LLM/OpenRouterProvider.ts b/front_end/panels/ai_chat/LLM/OpenRouterProvider.ts index 7acb066e22..2714933226 100644 --- a/front_end/panels/ai_chat/LLM/OpenRouterProvider.ts +++ b/front_end/panels/ai_chat/LLM/OpenRouterProvider.ts @@ -7,6 +7,7 @@ import { LLMBaseProvider } from './LLMProvider.js'; import { LLMRetryManager } from './LLMErrorHandler.js'; import { LLMResponseParser } from './LLMResponseParser.js'; import { createLogger } from '../core/Logger.js'; +import { getEnvironmentConfig } from '../core/EnvironmentConfig.js'; const logger = createLogger('OpenRouterProvider'); @@ -59,10 +60,29 @@ export class OpenRouterProvider extends LLMBaseProvider { private visionModelsCacheExpiry: number = 0; private static readonly CACHE_DURATION_MS = 30 * 60 * 1000; // 30 minutes + private readonly envConfig = getEnvironmentConfig(); + constructor(private readonly apiKey: string) { super(); } + /** + * Get the API key with fallback hierarchy: + * 1. Constructor parameter (for backward compatibility) + * 2. localStorage (user-configured) + * 3. Build-time environment config + * 4. Empty string + */ + private getApiKey(): string { + // Constructor parameter (highest priority for backward compatibility) + if (this.apiKey && this.apiKey.trim() !== '') { + return this.apiKey.trim(); + } + + // Use environment config which handles localStorage -> build-time -> empty fallback + return this.envConfig.getApiKey('openrouter'); + } + /** * Check if a model doesn't support temperature parameter * OpenAI's GPT-5, O3, and O4 models accessed through OpenRouter don't support temperature @@ -144,7 +164,7 @@ export class OpenRouterProvider extends LLMBaseProvider { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': `Bearer ${this.getApiKey()}`, 'HTTP-Referer': 'https://browseroperator.io', // Site URL for rankings on openrouter.ai 'X-Title': 'Browser Operator', // Site title for rankings on openrouter.ai }, @@ -324,7 +344,7 @@ export class OpenRouterProvider extends LLMBaseProvider { const response = await fetch(this.getToolSupportingModelsEndpoint(), { method: 'GET', headers: { - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': `Bearer ${this.getApiKey()}`, }, }); @@ -358,7 +378,7 @@ export class OpenRouterProvider extends LLMBaseProvider { const response = await fetch(this.getVisionModelsEndpoint(), { method: 'GET', headers: { - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': `Bearer ${this.getApiKey()}`, }, }); @@ -621,15 +641,20 @@ export class OpenRouterProvider extends LLMBaseProvider { logger.debug('=== VALIDATING OPENROUTER CREDENTIALS ==='); logger.debug('Timestamp:', new Date().toISOString()); - const storageKeys = this.getCredentialStorageKeys(); - logger.debug('Storage keys:', storageKeys); + // Use the new environment config for validation + const validationResult = this.envConfig.validateCredentials('openrouter'); + + // Enhanced logging for debugging + const apiKey = this.getApiKey(); + const source = this.envConfig.getApiKeySource('openrouter'); + const buildInfo = this.envConfig.getBuildInfo(); - const apiKey = localStorage.getItem(storageKeys.apiKey!); logger.debug('API key check:'); - logger.debug('- Storage key used:', storageKeys.apiKey); logger.debug('- API key exists:', !!apiKey); - logger.debug('- API key length:', apiKey?.length || 0); - logger.debug('- API key prefix:', apiKey?.substring(0, 8) + '...' || 'none'); + logger.debug('- API key presence:', apiKey ? '' : 'none'); + logger.debug('- API key source:', source); + logger.debug('- Build config available:', buildInfo.hasBuildConfig); + logger.debug('- Build time:', buildInfo.buildTime); // Also check OAuth-related storage for debugging const authMethod = localStorage.getItem('openrouter_auth_method'); @@ -638,37 +663,22 @@ export class OpenRouterProvider extends LLMBaseProvider { logger.debug('- Auth method:', authMethod); logger.debug('- OAuth token exists:', !!oauthToken); - // Check all OpenRouter-related localStorage keys - const allKeys = Object.keys(localStorage); - const openRouterKeys = allKeys.filter(key => key.includes('openrouter') || key.includes('ai_chat')); - logger.debug('All OpenRouter-related storage keys:'); - openRouterKeys.forEach(key => { - const value = localStorage.getItem(key); - logger.debug(`- ${key}:`, value?.substring(0, 50) + (value && value.length > 50 ? '...' : '') || 'null'); - }); - - if (!apiKey) { + if (validationResult.isValid) { + logger.info('✅ OpenRouter credentials validation passed'); + } else { logger.warn('❌ OpenRouter API key missing'); - return { - isValid: false, - message: 'OpenRouter API key is required. Please add your API key in Settings.', - missingItems: ['API Key'] - }; } - logger.info('✅ OpenRouter credentials validation passed'); - return { - isValid: true, - message: 'OpenRouter credentials are configured correctly.' - }; + return validationResult; } /** * Get the storage keys this provider uses for credentials */ getCredentialStorageKeys(): {apiKey: string} { + const storageKey = this.envConfig.getStorageKey('openrouter'); const keys = { - apiKey: 'ai_chat_openrouter_api_key' + apiKey: storageKey }; logger.debug('OpenRouter credential storage keys:', keys); return keys; diff --git a/front_end/panels/ai_chat/core/EnvironmentConfig.ts b/front_end/panels/ai_chat/core/EnvironmentConfig.ts new file mode 100644 index 0000000000..a53bd50315 --- /dev/null +++ b/front_end/panels/ai_chat/core/EnvironmentConfig.ts @@ -0,0 +1,337 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Environment Configuration Manager for AI Chat Panel + * + * This module provides unified access to API keys from multiple sources: + * 1. localStorage (user-configured, highest priority) + * 2. Runtime environment variables (Docker runtime injection) + * 3. Build-time environment variables (Docker build args, fallback) + * 4. Empty string (no configuration available) + * + * SECURITY NOTICE: + * - NEVER commit real API keys to source control + * - Runtime injection happens at container start (not in image) + * - Build-time injection is for LOCAL/DEV environments only + * - Production deployments should use server-side proxy/OAuth + * - The getBuildConfig() function returns safe defaults (no keys) + */ + +import { createLogger } from './Logger.js'; + +// Build-time configuration interface +interface BuildTimeConfig { + readonly apiKeys: { + readonly openai?: string; + readonly openrouter?: string; + readonly groq?: string; + readonly litellm?: string; + }; + readonly buildTime: string; + readonly hasKeys: boolean; +} + +// Default build configuration (used when no environment config is available) +const DEFAULT_BUILD_CONFIG: BuildTimeConfig = { + apiKeys: {}, + buildTime: 'development', + hasKeys: false +}; + +// Get build configuration - replaced at Docker build for local/dev only. +// IMPORTANT: Do not commit real keys. The default returns no keys. +function getBuildConfig(): BuildTimeConfig { + return DEFAULT_BUILD_CONFIG; +} + +const BUILD_CONFIG = getBuildConfig(); + +const logger = createLogger('EnvironmentConfig'); + +// Runtime configuration interface (injected by Docker at container start) +interface RuntimeConfig { + OPENAI_API_KEY?: string; + OPENROUTER_API_KEY?: string; + GROQ_API_KEY?: string; + LITELLM_API_KEY?: string; + timestamp?: string; + source?: string; +} + +// Get runtime configuration if available (from Docker runtime injection) +function getRuntimeConfig(): RuntimeConfig | null { + if (typeof window === 'undefined') { + return null; + } + + // @ts-ignore - __RUNTIME_CONFIG__ is injected by Docker entrypoint + if (window.__RUNTIME_CONFIG__) { + // @ts-ignore + const config = window.__RUNTIME_CONFIG__ as RuntimeConfig; + console.log('Runtime config found:', { + hasOpenAI: Boolean(config.OPENAI_API_KEY), + hasOpenRouter: Boolean(config.OPENROUTER_API_KEY), + hasGroq: Boolean(config.GROQ_API_KEY), + hasLiteLLM: Boolean(config.LITELLM_API_KEY), + timestamp: config.timestamp, + source: config.source + }); + return config; + } + + console.log('Runtime config not found on window object'); + return null; +} + +/** + * API key providers supported by the environment configuration + */ +export type APIKeyProvider = 'openai' | 'openrouter' | 'groq' | 'litellm'; + +/** + * Storage keys for localStorage API keys + */ +const STORAGE_KEYS: Record = { + openai: 'ai_chat_api_key', + openrouter: 'ai_chat_openrouter_api_key', + groq: 'ai_chat_groq_api_key', + litellm: 'ai_chat_litellm_api_key' +}; + +/** + * Environment Configuration Manager + * + * Provides unified access to API keys with fallback hierarchy: + * localStorage → build-time config → empty string + */ +export class EnvironmentConfig { + private static instance: EnvironmentConfig | null = null; + private debugLogged = false; + private runtimeConfig: RuntimeConfig | null = null; + + private constructor() { + // Get runtime configuration if available + this.runtimeConfig = getRuntimeConfig(); + + // Auto-save runtime config to localStorage if not already present + this.initializeFromRuntime(); + + // Log configuration availability once for debugging + if (!this.debugLogged) { + logger.debug('Environment configuration initialized:', { + hasRuntimeConfig: Boolean(this.runtimeConfig), + hasBuildConfig: BUILD_CONFIG?.hasKeys || false, + buildTime: BUILD_CONFIG?.buildTime || 'unknown', + availableProviders: BUILD_CONFIG ? Object.keys(BUILD_CONFIG.apiKeys) : [] + }); + this.debugLogged = true; + } + } + + /** + * Initialize API keys from runtime config if available + * Saves runtime-injected keys to localStorage if not already present + */ + private initializeFromRuntime(): void { + if (!this.runtimeConfig) { + return; + } + + const providers: APIKeyProvider[] = ['openai', 'openrouter', 'groq', 'litellm']; + let savedCount = 0; + + for (const provider of providers) { + const storageKey = STORAGE_KEYS[provider]; + const existingKey = localStorage.getItem(storageKey); + + // Only save if not already in localStorage + if (!existingKey || existingKey.trim() === '') { + const runtimeKey = this.getRuntimeKey(provider); + if (runtimeKey) { + localStorage.setItem(storageKey, runtimeKey); + savedCount++; + logger.debug(`Saved runtime API key to localStorage for ${provider}`); + } + } + } + + if (savedCount > 0) { + logger.info(`Initialized ${savedCount} API keys from Docker runtime configuration`); + } + } + + /** + * Get API key from runtime config + */ + private getRuntimeKey(provider: APIKeyProvider): string { + if (!this.runtimeConfig) { + return ''; + } + + const keyMap: Record = { + openai: 'OPENAI_API_KEY', + openrouter: 'OPENROUTER_API_KEY', + groq: 'GROQ_API_KEY', + litellm: 'LITELLM_API_KEY' + }; + + const key = this.runtimeConfig[keyMap[provider]]; + return (key && key.trim() !== '') ? key.trim() : ''; + } + + /** + * Get the singleton instance of EnvironmentConfig + */ + static getInstance(): EnvironmentConfig { + if (!EnvironmentConfig.instance) { + EnvironmentConfig.instance = new EnvironmentConfig(); + } + return EnvironmentConfig.instance; + } + + /** + * Get API key for a specific provider with fallback hierarchy + * + * Priority order: + * 1. localStorage (user-configured) + * 2. Runtime configuration (Docker runtime injection) + * 3. Build-time environment config (Docker build args) + * 4. Empty string (no configuration) + * + * @param provider The API key provider + * @returns The API key or empty string if not available + */ + getApiKey(provider: APIKeyProvider): string { + // First check localStorage (highest priority) + const storageKey = STORAGE_KEYS[provider]; + const localStorageKey = localStorage.getItem(storageKey); + + if (localStorageKey && localStorageKey.trim() !== '') { + logger.debug(`Using localStorage API key for ${provider}`); + return localStorageKey.trim(); + } + + // Check runtime configuration (Docker runtime injection) + const runtimeKey = this.getRuntimeKey(provider); + if (runtimeKey) { + logger.debug(`Using runtime API key for ${provider}`); + // Also save to localStorage for future use + localStorage.setItem(storageKey, runtimeKey); + return runtimeKey; + } + + // Fallback to build-time configuration + if (BUILD_CONFIG?.apiKeys?.[provider]) { + logger.debug(`Using build-time API key for ${provider}`); + return BUILD_CONFIG.apiKeys[provider]; + } + + // No configuration available + logger.debug(`No API key available for ${provider}`); + return ''; + } + + /** + * Check if an API key is available for a provider + * + * @param provider The API key provider + * @returns true if an API key is available from any source + */ + hasApiKey(provider: APIKeyProvider): boolean { + return this.getApiKey(provider) !== ''; + } + + /** + * Get the source of an API key for debugging + * + * @param provider The API key provider + * @returns The source of the API key ('localStorage', 'runtime', 'build-time', or 'none') + */ + getApiKeySource(provider: APIKeyProvider): 'localStorage' | 'runtime' | 'build-time' | 'none' { + const storageKey = STORAGE_KEYS[provider]; + const localStorageKey = localStorage.getItem(storageKey); + + if (localStorageKey && localStorageKey.trim() !== '') { + return 'localStorage'; + } + + if (this.getRuntimeKey(provider)) { + return 'runtime'; + } + + if (typeof BUILD_CONFIG?.apiKeys?.[provider] === 'string' && + BUILD_CONFIG.apiKeys[provider].trim() !== '') { + return 'build-time'; + } + + return 'none'; + } + + /** + * Get storage key for a provider (for backward compatibility) + * + * @param provider The API key provider + * @returns The localStorage key used for this provider + */ + getStorageKey(provider: APIKeyProvider): string { + return STORAGE_KEYS[provider]; + } + + /** + * Validate credentials for a provider + * + * @param provider The API key provider + * @returns Validation result with details + */ + validateCredentials(provider: APIKeyProvider): { + isValid: boolean; + message: string; + source?: 'localStorage' | 'build-time'; + missingItems?: string[]; + } { + const apiKey = this.getApiKey(provider); + const source = this.getApiKeySource(provider); + + if (!apiKey) { + return { + isValid: false, + message: `${provider} API key is required. Please add your API key in Settings or configure environment variables.`, + missingItems: ['API Key'] + }; + } + + return { + isValid: true, + message: `${provider} credentials are configured correctly (source: ${source}).`, + source: source !== 'none' ? source : undefined + }; + } + + /** + * Get build configuration info for debugging + * + * @returns Build configuration metadata + */ + getBuildInfo(): { + hasBuildConfig: boolean; + buildTime: string; + availableProviders: string[]; + } { + return { + hasBuildConfig: BUILD_CONFIG?.hasKeys || false, + buildTime: BUILD_CONFIG?.buildTime || 'unknown', + availableProviders: BUILD_CONFIG ? Object.keys(BUILD_CONFIG.apiKeys) : [] + }; + } +} + +/** + * Get the global environment configuration instance + * + * @returns The EnvironmentConfig singleton + */ +export function getEnvironmentConfig(): EnvironmentConfig { + return EnvironmentConfig.getInstance(); +} \ No newline at end of file diff --git a/front_end/panels/ai_chat/ui/SettingsDialog.ts b/front_end/panels/ai_chat/ui/SettingsDialog.ts index 91f7739c2c..eb64f2ae03 100644 --- a/front_end/panels/ai_chat/ui/SettingsDialog.ts +++ b/front_end/panels/ai_chat/ui/SettingsDialog.ts @@ -8,6 +8,7 @@ import { getEvaluationConfig, setEvaluationConfig, isEvaluationEnabled, connectT import { createLogger } from '../core/Logger.js'; import { LLMClient } from '../LLM/LLMClient.js'; import { getTracingConfig, setTracingConfig, isTracingEnabled } from '../tracing/TracingConfig.js'; +import { getEnvironmentConfig } from '../core/EnvironmentConfig.js'; import { DEFAULT_PROVIDER_MODELS } from './AIChatPanel.js'; @@ -525,7 +526,7 @@ export class SettingsDialog { } } else if (selectedProvider === 'groq') { // If switching to Groq, fetch models if API key is configured - const groqApiKey = groqApiKeyInput.value.trim() || localStorage.getItem('ai_chat_groq_api_key') || ''; + const groqApiKey = groqApiKeyInput.value.trim() || envConfig.getApiKey('groq'); if (groqApiKey) { try { @@ -544,7 +545,7 @@ export class SettingsDialog { } } else if (selectedProvider === 'openrouter') { // If switching to OpenRouter, fetch models if API key is configured - const openrouterApiKey = openrouterApiKeyInput.value.trim() || localStorage.getItem('ai_chat_openrouter_api_key') || ''; + const openrouterApiKey = openrouterApiKeyInput.value.trim() || envConfig.getApiKey('openrouter'); if (openrouterApiKey) { try { @@ -600,7 +601,8 @@ export class SettingsDialog { apiKeyHint.textContent = i18nString(UIStrings.apiKeyHint); openaiSettingsSection.appendChild(apiKeyHint); - const settingsSavedApiKey = localStorage.getItem('ai_chat_api_key') || ''; + const envConfig = getEnvironmentConfig(); + const settingsSavedApiKey = envConfig.getApiKey('openai'); const settingsApiKeyInput = document.createElement('input'); settingsApiKeyInput.className = 'settings-input'; settingsApiKeyInput.type = 'password'; @@ -710,7 +712,7 @@ export class SettingsDialog { litellmAPIKeyHint.textContent = i18nString(UIStrings.liteLLMApiKeyHint); litellmSettingsSection.appendChild(litellmAPIKeyHint); - const settingsSavedLiteLLMApiKey = localStorage.getItem(LITELLM_API_KEY_STORAGE_KEY) || ''; + const settingsSavedLiteLLMApiKey = envConfig.getApiKey('litellm'); const litellmApiKeyInput = document.createElement('input'); litellmApiKeyInput.className = 'settings-input litellm-api-key-input'; litellmApiKeyInput.type = 'password'; @@ -1266,7 +1268,7 @@ export class SettingsDialog { groqApiKeyHint.textContent = i18nString(UIStrings.groqApiKeyHint); groqSettingsSection.appendChild(groqApiKeyHint); - const settingsSavedGroqApiKey = localStorage.getItem(GROQ_API_KEY_STORAGE_KEY) || ''; + const settingsSavedGroqApiKey = envConfig.getApiKey('groq'); const groqApiKeyInput = document.createElement('input'); groqApiKeyInput.className = 'settings-input groq-api-key-input'; groqApiKeyInput.type = 'password'; @@ -1452,7 +1454,7 @@ export class SettingsDialog { openrouterApiKeyHint.textContent = i18nString(UIStrings.openrouterApiKeyHint); openrouterSettingsSection.appendChild(openrouterApiKeyHint); - const settingsSavedOpenRouterApiKey = localStorage.getItem(OPENROUTER_API_KEY_STORAGE_KEY) || ''; + const settingsSavedOpenRouterApiKey = envConfig.getApiKey('openrouter'); const openrouterApiKeyInput = document.createElement('input'); openrouterApiKeyInput.className = 'settings-input openrouter-api-key-input'; openrouterApiKeyInput.type = 'password';