Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# options for analysis running
version: "2"
run:
timeout: 5m

linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
Expand Down
50 changes: 29 additions & 21 deletions cmd/gomodel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ package main
import (
"log/slog"
"os"
"sort"

"gomodel/config"
"gomodel/internal/core"
"gomodel/internal/providers"
"gomodel/internal/providers/anthropic"
"gomodel/internal/providers/gemini"
"gomodel/internal/providers/openai"
// Import provider packages to trigger their init() registration
_ "gomodel/internal/providers/anthropic"
_ "gomodel/internal/providers/gemini"
_ "gomodel/internal/providers/openai"
"gomodel/internal/server"
)

Expand All @@ -26,35 +28,41 @@ func main() {
os.Exit(1)
}

// Validate that at least one API key is provided
if cfg.OpenAI.APIKey == "" && cfg.Anthropic.APIKey == "" && cfg.Gemini.APIKey == "" {
slog.Error("at least one API key is required (OPENAI_API_KEY, ANTHROPIC_API_KEY, or GEMINI_API_KEY)")
// Validate that at least one provider is configured
if len(cfg.Providers) == 0 {
slog.Error("at least one provider must be configured")
os.Exit(1)
}

// Create providers
providerList := make([]core.Provider, 0, 3)
// Create providers dynamically using the factory
activeProviders := make([]core.Provider, 0, len(cfg.Providers))

if cfg.OpenAI.APIKey != "" {
openaiProvider := openai.New(cfg.OpenAI.APIKey)
providerList = append(providerList, openaiProvider)
slog.Info("OpenAI provider initialized")
// Sort provider names for deterministic initialization order
providerNames := make([]string, 0, len(cfg.Providers))
for name := range cfg.Providers {
providerNames = append(providerNames, name)
}
sort.Strings(providerNames)

if cfg.Anthropic.APIKey != "" {
anthropicProvider := anthropic.New(cfg.Anthropic.APIKey)
providerList = append(providerList, anthropicProvider)
slog.Info("Anthropic provider initialized")
for _, name := range providerNames {
pCfg := cfg.Providers[name]
p, err := providers.Create(pCfg)
if err != nil {
slog.Error("failed to initialize provider", "name", name, "type", pCfg.Type, "error", err)
continue
}
activeProviders = append(activeProviders, p)
slog.Info("provider initialized", "name", name, "type", pCfg.Type)
}

if cfg.Gemini.APIKey != "" {
geminiProvider := gemini.New(cfg.Gemini.APIKey)
providerList = append(providerList, geminiProvider)
slog.Info("Gemini provider initialized")
// Validate that at least one provider was successfully initialized
if len(activeProviders) == 0 {
slog.Error("no providers were successfully initialized")
os.Exit(1)
}

// Create provider router
provider := providers.NewRouter(providerList...)
provider := providers.NewRouter(activeProviders...)

// Create and start server
srv := server.New(provider)
Expand Down
169 changes: 122 additions & 47 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,80 +2,155 @@
package config

import (
"os"
"strings"

"github.com/joho/godotenv"
"github.com/spf13/viper"
)

// Config holds the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
OpenAI OpenAIConfig `mapstructure:"openai"`
Anthropic AnthropicConfig `mapstructure:"anthropic"`
Gemini GeminiConfig `mapstructure:"gemini"`
Server ServerConfig `mapstructure:"server"`
Providers map[string]ProviderConfig `mapstructure:"providers"`
}

// ServerConfig holds HTTP server configuration
type ServerConfig struct {
Port string `mapstructure:"port"`
}

// OpenAIConfig holds OpenAI-specific configuration
type OpenAIConfig struct {
APIKey string `mapstructure:"api_key"`
}

// AnthropicConfig holds Anthropic-specific configuration
type AnthropicConfig struct {
APIKey string `mapstructure:"api_key"`
}

// GeminiConfig holds Google Gemini-specific configuration
type GeminiConfig struct {
APIKey string `mapstructure:"api_key"`
// ProviderConfig holds generic provider configuration
type ProviderConfig struct {
Type string `mapstructure:"type"` // e.g., "openai", "anthropic", "gemini"
APIKey string `mapstructure:"api_key"` // API key for authentication
BaseURL string `mapstructure:"base_url"` // Optional: override default base URL
Models []string `mapstructure:"models"` // Optional: restrict to specific models
}

// Load reads configuration from file and environment
func Load() (*Config, error) {
// Load .env file directly into environment variables
// This ensures os.Getenv works for variables defined in .env
_ = godotenv.Load() // Ignore error (e.g., file not found)

// Load .env file using Viper (optional, won't fail if not found)
viper.SetConfigName(".env")

viper.SetConfigType("env")
viper.AddConfigPath(".")
_ = viper.ReadInConfig() // Ignore error if .env file doesn't exist

// Set defaults
viper.SetDefault("PORT", "8080")
viper.SetDefault("server.port", "8080")

// Enable automatic environment variable reading
viper.AutomaticEnv()

// Commented out: config.yaml reading (not used anymore)
// viper.SetConfigName("config")
// viper.SetConfigType("yaml")
// viper.AddConfigPath("./config")
// viper.AddConfigPath(".")
//
// // Read config file (optional, won't fail if not found)
// _ = viper.ReadInConfig() //nolint:errcheck
//
// var cfg Config
// if err := viper.Unmarshal(&cfg); err != nil {
// return nil, err
// }

// Read configuration from environment variables using Viper
cfg := &Config{
Server: ServerConfig{
Port: viper.GetString("PORT"),
},
OpenAI: OpenAIConfig{
APIKey: viper.GetString("OPENAI_API_KEY"),
},
Anthropic: AnthropicConfig{
APIKey: viper.GetString("ANTHROPIC_API_KEY"),
},
Gemini: GeminiConfig{
APIKey: viper.GetString("GEMINI_API_KEY"),
},
// Try to read config.yaml
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./config")
viper.AddConfigPath(".")

var cfg Config

// Read config file (optional, won't fail if not found)
if err := viper.ReadInConfig(); err == nil {
// Config file found, unmarshal it
if err := viper.Unmarshal(&cfg); err != nil {
return nil, err
}
// Expand environment variables in config values
cfg = expandEnvVars(cfg)
// Remove providers with unresolved environment variables
cfg = removeEmptyProviders(cfg)
} else {
// No config file, use environment variables (legacy support)
cfg = Config{
Server: ServerConfig{
Port: viper.GetString("PORT"),
Comment thread
SantiagoDePolonia marked this conversation as resolved.
},
Providers: make(map[string]ProviderConfig),
}

// Add providers from environment variables if available
if apiKey := viper.GetString("OPENAI_API_KEY"); apiKey != "" {
cfg.Providers["openai-primary"] = ProviderConfig{
Type: "openai",
APIKey: apiKey,
}
}
if apiKey := viper.GetString("ANTHROPIC_API_KEY"); apiKey != "" {
cfg.Providers["anthropic-primary"] = ProviderConfig{
Type: "anthropic",
APIKey: apiKey,
}
}
if apiKey := viper.GetString("GEMINI_API_KEY"); apiKey != "" {
cfg.Providers["gemini-primary"] = ProviderConfig{
Type: "gemini",
APIKey: apiKey,
}
}
}

return cfg, nil
return &cfg, nil
}

// expandEnvVars expands environment variable references in configuration values
func expandEnvVars(cfg Config) Config {
// Expand server port
cfg.Server.Port = expandString(cfg.Server.Port)

// Expand provider configurations
for name, pCfg := range cfg.Providers {
pCfg.APIKey = expandString(pCfg.APIKey)
pCfg.BaseURL = expandString(pCfg.BaseURL)
cfg.Providers[name] = pCfg
}

return cfg
}

// expandString expands environment variable references like ${VAR_NAME} or ${VAR_NAME:-default} in a string
func expandString(s string) string {
if s == "" {
return s
}
return os.Expand(s, func(key string) string {
// Check for default value syntax ${VAR:-default}
varname := key
defaultValue := ""
if strings.Contains(key, ":-") {
parts := strings.SplitN(key, ":-", 2)
varname = parts[0]
defaultValue = parts[1]
}

// Try to get from environment
value := os.Getenv(varname)
if value == "" {
if defaultValue != "" {
return defaultValue
}
// If not in environment and no default, return the original placeholder
// This allows config to work with or without env vars
return "${" + key + "}"
}
return value
})
Comment on lines +121 to +142

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expandString function preserves unresolved environment variable placeholders by returning "${" + key + "}". This could lead to API keys containing literal placeholder strings (e.g., "${OPENAI_API_KEY}"), which would then be sent to provider APIs as invalid credentials. While removeEmptyProviders filters providers with placeholders in API keys, this doesn't handle the case where BaseURL contains unresolved placeholders. Consider either logging a warning when placeholders can't be resolved, or returning an empty string to make the failure more explicit.

Copilot uses AI. Check for mistakes.
}

// removeEmptyProviders removes providers with empty API keys
func removeEmptyProviders(cfg Config) Config {
filteredProviders := make(map[string]ProviderConfig)
for name, pCfg := range cfg.Providers {
// Keep provider only if API key doesn't contain unexpanded placeholders
if pCfg.APIKey != "" && !strings.Contains(pCfg.APIKey, "${") {
filteredProviders[name] = pCfg
}
}
cfg.Providers = filteredProviders
return cfg
}
32 changes: 32 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,33 @@
server:
port: "${PORT:-8080}"

providers:
openai-primary:
type: "openai"
api_key: "${OPENAI_API_KEY}"

anthropic-primary:
type: "anthropic"
api_key: "${ANTHROPIC_API_KEY}"

gemini-primary:
type: "gemini"
api_key: "${GEMINI_API_KEY}"

# Example: Groq (OpenAI-compatible)
# groq:
# type: "openai"
# base_url: "https://api.groq.com/openai/v1"
# api_key: "${GROQ_API_KEY}"

# Example: Azure OpenAI
# azure-openai:
# type: "openai"
# base_url: "https://your-resource.openai.azure.com/openai/deployments/your-deployment"
# api_key: "${AZURE_OPENAI_API_KEY}"

# Example: DeepSeek (OpenAI-compatible)
# deepseek:
# type: "openai"
# base_url: "https://api.deepseek.com/v1"
# api_key: "${DEEPSEEK_API_KEY}"
Loading