Update config structure

This commit is contained in:
2026-03-01 19:21:58 +00:00
parent b5f21f385a
commit 27e68f8e4c
10 changed files with 220 additions and 133 deletions

View File

@@ -212,20 +212,24 @@ The gateway supports Azure OpenAI with the same interface as standard OpenAI:
```yaml ```yaml
providers: providers:
azureopenai: azureopenai:
type: "azureopenai"
api_key: "${AZURE_OPENAI_API_KEY}" api_key: "${AZURE_OPENAI_API_KEY}"
endpoint: "https://your-resource.openai.azure.com" endpoint: "https://your-resource.openai.azure.com"
deployment_id: "your-deployment-name"
models:
- name: "gpt-4o"
provider: "azureopenai"
provider_model_id: "my-gpt4o-deployment" # optional: defaults to name
``` ```
```bash ```bash
export AZURE_OPENAI_API_KEY="..." export AZURE_OPENAI_API_KEY="..."
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com"
export AZURE_OPENAI_DEPLOYMENT_ID="gpt-4o"
./gateway ./gateway
``` ```
The gateway prefers Azure OpenAI for `gpt-*` models if configured. See **[AZURE_OPENAI.md](./AZURE_OPENAI.md)** for complete setup guide. The `provider_model_id` field lets you map a friendly model name to the actual provider identifier (e.g., an Azure deployment name). If omitted, the model `name` is used directly. See **[AZURE_OPENAI.md](./AZURE_OPENAI.md)** for complete setup guide.
## Authentication ## Authentication

View File

@@ -24,7 +24,7 @@ func main() {
log.Fatalf("load config: %v", err) log.Fatalf("load config: %v", err)
} }
registry, err := providers.NewRegistry(cfg.Providers) registry, err := providers.NewRegistry(cfg.Providers, cfg.Models)
if err != nil { if err != nil {
log.Fatalf("init providers: %v", err) log.Fatalf("init providers: %v", err)
} }

View File

@@ -1,19 +0,0 @@
# Example configuration with Google OAuth2 authentication
auth:
enabled: true
issuer: "https://accounts.google.com"
audience: "YOUR-CLIENT-ID.apps.googleusercontent.com"
providers:
openai:
api_key: "${OPENAI_API_KEY}"
model: "gpt-4o-mini"
anthropic:
api_key: "${ANTHROPIC_API_KEY}"
model: "claude-3-5-sonnet-20241022"
google:
api_key: "${GOOGLE_API_KEY}"
model: "gemini-2.0-flash-exp"

View File

@@ -3,19 +3,38 @@ server:
providers: providers:
google: google:
type: "google"
api_key: "YOUR_GOOGLE_API_KEY" api_key: "YOUR_GOOGLE_API_KEY"
model: "gemini-1.5-flash"
endpoint: "https://generativelanguage.googleapis.com" endpoint: "https://generativelanguage.googleapis.com"
anthropic: anthropic:
type: "anthropic"
api_key: "YOUR_ANTHROPIC_API_KEY" api_key: "YOUR_ANTHROPIC_API_KEY"
model: "claude-3-5-sonnet"
endpoint: "https://api.anthropic.com" endpoint: "https://api.anthropic.com"
openai: openai:
type: "openai"
api_key: "YOUR_OPENAI_API_KEY" api_key: "YOUR_OPENAI_API_KEY"
model: "gpt-4o-mini"
endpoint: "https://api.openai.com" endpoint: "https://api.openai.com"
# Azure-hosted Anthropic (Microsoft Foundry) - optional, overrides anthropic if set # Azure OpenAI - optional
# azureopenai:
# type: "azureopenai"
# api_key: "YOUR_AZURE_OPENAI_API_KEY"
# endpoint: "https://your-resource.openai.azure.com"
# api_version: "2024-12-01-preview"
# Azure-hosted Anthropic (Microsoft Foundry) - optional
# azureanthropic: # azureanthropic:
# type: "azureanthropic"
# api_key: "YOUR_AZURE_ANTHROPIC_API_KEY" # api_key: "YOUR_AZURE_ANTHROPIC_API_KEY"
# endpoint: "https://your-resource.services.ai.azure.com/anthropic" # endpoint: "https://your-resource.services.ai.azure.com/anthropic"
# model: "claude-sonnet-4-5-20250514"
models:
- name: "gemini-1.5-flash"
provider: "google"
- name: "claude-3-5-sonnet"
provider: "anthropic"
- name: "gpt-4o-mini"
provider: "openai"
# - name: "gpt-4o"
# provider: "azureopenai"
# provider_model_id: "my-gpt4o-deployment" # optional: defaults to name
# - name: "claude-sonnet-4-5-20250514"
# provider: "azureanthropic"

View File

@@ -64,6 +64,18 @@ type StreamDelta struct {
Content []ContentBlock `json:"content,omitempty"` Content []ContentBlock `json:"content,omitempty"`
} }
// ModelInfo describes a single model available through the gateway.
type ModelInfo struct {
ID string `json:"id"`
Provider string `json:"provider"`
}
// ModelsResponse is returned by the GET /v1/models endpoint.
type ModelsResponse struct {
Object string `json:"object"`
Data []ModelInfo `json:"data"`
}
// Validate performs basic structural validation. // Validate performs basic structural validation.
func (r *ResponseRequest) Validate() error { func (r *ResponseRequest) Validate() error {
if r == nil { if r == nil {

View File

@@ -10,7 +10,8 @@ import (
// Config describes the full gateway configuration file. // Config describes the full gateway configuration file.
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Providers ProvidersConfig `yaml:"providers"` Providers map[string]ProviderEntry `yaml:"providers"`
Models []ModelEntry `yaml:"models"`
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
} }
@@ -26,89 +27,68 @@ type ServerConfig struct {
Address string `yaml:"address"` Address string `yaml:"address"`
} }
// ProvidersConfig wraps supported provider settings. // ProviderEntry defines a named provider instance in the config file.
type ProvidersConfig struct { type ProviderEntry struct {
Google ProviderConfig `yaml:"google"` Type string `yaml:"type"`
Anthropic ProviderConfig `yaml:"anthropic"`
OpenAI ProviderConfig `yaml:"openai"`
AzureOpenAI AzureOpenAIConfig `yaml:"azureopenai"`
AzureAnthropic AzureAnthropicConfig `yaml:"azureanthropic"`
}
// AzureAnthropicConfig contains Azure-specific settings for Anthropic (Microsoft Foundry).
type AzureAnthropicConfig struct {
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
Endpoint string `yaml:"endpoint"` Endpoint string `yaml:"endpoint"`
Model string `yaml:"model"` APIVersion string `yaml:"api_version"`
} }
// ProviderConfig contains shared provider configuration fields. // ModelEntry maps a model name to a provider entry.
type ModelEntry struct {
Name string `yaml:"name"`
Provider string `yaml:"provider"`
ProviderModelID string `yaml:"provider_model_id"`
}
// ProviderConfig contains shared provider configuration fields used internally by providers.
type ProviderConfig struct { type ProviderConfig struct {
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
Model string `yaml:"model"` Model string `yaml:"model"`
Endpoint string `yaml:"endpoint"` Endpoint string `yaml:"endpoint"`
} }
// AzureOpenAIConfig contains Azure-specific settings. // AzureOpenAIConfig contains Azure-specific settings used internally by the OpenAI provider.
type AzureOpenAIConfig struct { type AzureOpenAIConfig struct {
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
Endpoint string `yaml:"endpoint"` Endpoint string `yaml:"endpoint"`
DeploymentID string `yaml:"deployment_id"`
APIVersion string `yaml:"api_version"` APIVersion string `yaml:"api_version"`
} }
// Load reads and parses a YAML configuration file and applies env overrides. // AzureAnthropicConfig contains Azure-specific settings for Anthropic used internally.
type AzureAnthropicConfig struct {
APIKey string `yaml:"api_key"`
Endpoint string `yaml:"endpoint"`
Model string `yaml:"model"`
}
// Load reads and parses a YAML configuration file, expanding ${VAR} env references.
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("read config: %w", err) return nil, fmt.Errorf("read config: %w", err)
} }
expanded := os.Expand(string(data), os.Getenv)
var cfg Config var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err) return nil, fmt.Errorf("parse config: %w", err)
} }
cfg.applyEnvOverrides() if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil return &cfg, nil
} }
func (cfg *Config) applyEnvOverrides() { func (cfg *Config) validate() error {
overrideAPIKey(&cfg.Providers.Google, "GOOGLE_API_KEY") for _, m := range cfg.Models {
overrideAPIKey(&cfg.Providers.Anthropic, "ANTHROPIC_API_KEY") if _, ok := cfg.Providers[m.Provider]; !ok {
overrideAPIKey(&cfg.Providers.OpenAI, "OPENAI_API_KEY") return fmt.Errorf("model %q references unknown provider %q", m.Name, m.Provider)
// Azure OpenAI overrides
if v := os.Getenv("AZURE_OPENAI_API_KEY"); v != "" {
cfg.Providers.AzureOpenAI.APIKey = v
}
if v := os.Getenv("AZURE_OPENAI_ENDPOINT"); v != "" {
cfg.Providers.AzureOpenAI.Endpoint = v
}
if v := os.Getenv("AZURE_OPENAI_DEPLOYMENT_ID"); v != "" {
cfg.Providers.AzureOpenAI.DeploymentID = v
}
if v := os.Getenv("AZURE_OPENAI_API_VERSION"); v != "" {
cfg.Providers.AzureOpenAI.APIVersion = v
}
// Azure Anthropic (Microsoft Foundry) overrides
if v := os.Getenv("AZURE_ANTHROPIC_API_KEY"); v != "" {
cfg.Providers.AzureAnthropic.APIKey = v
}
if v := os.Getenv("AZURE_ANTHROPIC_ENDPOINT"); v != "" {
cfg.Providers.AzureAnthropic.Endpoint = v
}
if v := os.Getenv("AZURE_ANTHROPIC_MODEL"); v != "" {
cfg.Providers.AzureAnthropic.Model = v
} }
} }
return nil
func overrideAPIKey(cfg *ProviderConfig, envKey string) {
if cfg == nil {
return
}
if v := os.Getenv(envKey); v != "" {
cfg.APIKey = v
}
} }

View File

@@ -55,7 +55,6 @@ func NewAzure(azureCfg config.AzureOpenAIConfig) *Provider {
return &Provider{ return &Provider{
cfg: config.ProviderConfig{ cfg: config.ProviderConfig{
APIKey: azureCfg.APIKey, APIKey: azureCfg.APIKey,
Model: azureCfg.DeploymentID,
}, },
client: client, client: client,
azure: true, azure: true,

View File

@@ -3,7 +3,6 @@ package providers
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/yourusername/go-llm-gateway/internal/api" "github.com/yourusername/go-llm-gateway/internal/api"
"github.com/yourusername/go-llm-gateway/internal/config" "github.com/yourusername/go-llm-gateway/internal/config"
@@ -19,27 +18,38 @@ type Provider interface {
GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error) GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error)
} }
// Registry keeps track of registered providers by key (e.g. "openai"). // Registry keeps track of registered providers and model-to-provider mappings.
type Registry struct { type Registry struct {
providers map[string]Provider providers map[string]Provider
models map[string]string // model name -> provider entry name
providerModelIDs map[string]string // model name -> provider model ID
modelList []config.ModelEntry
} }
// NewRegistry constructs provider implementations from configuration. // NewRegistry constructs provider implementations from configuration.
func NewRegistry(cfg config.ProvidersConfig) (*Registry, error) { func NewRegistry(entries map[string]config.ProviderEntry, models []config.ModelEntry) (*Registry, error) {
reg := &Registry{providers: make(map[string]Provider)} reg := &Registry{
providers: make(map[string]Provider),
models: make(map[string]string),
providerModelIDs: make(map[string]string),
modelList: models,
}
if cfg.Google.APIKey != "" { for name, entry := range entries {
reg.providers[googleprovider.Name] = googleprovider.New(cfg.Google) p, err := buildProvider(entry)
if err != nil {
return nil, fmt.Errorf("provider %q: %w", name, err)
} }
if cfg.AzureAnthropic.APIKey != "" && cfg.AzureAnthropic.Endpoint != "" { if p != nil {
reg.providers[anthropicprovider.Name] = anthropicprovider.NewAzure(cfg.AzureAnthropic) reg.providers[name] = p
} else if cfg.Anthropic.APIKey != "" { }
reg.providers[anthropicprovider.Name] = anthropicprovider.New(cfg.Anthropic) }
for _, m := range models {
reg.models[m.Name] = m.Provider
if m.ProviderModelID != "" {
reg.providerModelIDs[m.Name] = m.ProviderModelID
} }
if cfg.AzureOpenAI.APIKey != "" && cfg.AzureOpenAI.Endpoint != "" {
reg.providers[openaiprovider.Name] = openaiprovider.NewAzure(cfg.AzureOpenAI)
} else if cfg.OpenAI.APIKey != "" {
reg.providers[openaiprovider.Name] = openaiprovider.New(cfg.OpenAI)
} }
if len(reg.providers) == 0 { if len(reg.providers) == 0 {
@@ -49,26 +59,77 @@ func NewRegistry(cfg config.ProvidersConfig) (*Registry, error) {
return reg, nil return reg, nil
} }
// Get returns provider by key. func buildProvider(entry config.ProviderEntry) (Provider, error) {
if entry.APIKey == "" {
return nil, nil
}
switch entry.Type {
case "openai":
return openaiprovider.New(config.ProviderConfig{
APIKey: entry.APIKey,
Endpoint: entry.Endpoint,
}), nil
case "azureopenai":
if entry.Endpoint == "" {
return nil, fmt.Errorf("endpoint is required for azureopenai")
}
return openaiprovider.NewAzure(config.AzureOpenAIConfig{
APIKey: entry.APIKey,
Endpoint: entry.Endpoint,
APIVersion: entry.APIVersion,
}), nil
case "anthropic":
return anthropicprovider.New(config.ProviderConfig{
APIKey: entry.APIKey,
Endpoint: entry.Endpoint,
}), nil
case "azureanthropic":
if entry.Endpoint == "" {
return nil, fmt.Errorf("endpoint is required for azureanthropic")
}
return anthropicprovider.NewAzure(config.AzureAnthropicConfig{
APIKey: entry.APIKey,
Endpoint: entry.Endpoint,
}), nil
case "google":
return googleprovider.New(config.ProviderConfig{
APIKey: entry.APIKey,
Endpoint: entry.Endpoint,
}), nil
default:
return nil, fmt.Errorf("unknown provider type %q", entry.Type)
}
}
// Get returns provider by entry name.
func (r *Registry) Get(name string) (Provider, bool) { func (r *Registry) Get(name string) (Provider, bool) {
p, ok := r.providers[name] p, ok := r.providers[name]
return p, ok return p, ok
} }
// Default returns provider based on inferred name. // Models returns the list of configured models and their provider entry names.
func (r *Registry) Models() []struct{ Provider, Model string } {
var out []struct{ Provider, Model string }
for _, m := range r.modelList {
out = append(out, struct{ Provider, Model string }{Provider: m.Provider, Model: m.Name})
}
return out
}
// ResolveModelID returns the provider_model_id for a model, falling back to the model name itself.
func (r *Registry) ResolveModelID(model string) string {
if id, ok := r.providerModelIDs[model]; ok {
return id
}
return model
}
// Default returns the provider for the given model name.
func (r *Registry) Default(model string) (Provider, error) { func (r *Registry) Default(model string) (Provider, error) {
if model != "" { if model != "" {
switch { if providerName, ok := r.models[model]; ok {
case strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3"): if p, ok := r.providers[providerName]; ok {
if p, ok := r.providers[openaiprovider.Name]; ok {
return p, nil
}
case strings.HasPrefix(model, "claude"):
if p, ok := r.providers[anthropicprovider.Name]; ok {
return p, nil
}
case strings.HasPrefix(model, "gemini"):
if p, ok := r.providers[googleprovider.Name]; ok {
return p, nil return p, nil
} }
} }

View File

@@ -30,6 +30,31 @@ func New(registry *providers.Registry, convs *conversation.Store, logger *log.Lo
// RegisterRoutes wires the HTTP handlers onto the provided mux. // RegisterRoutes wires the HTTP handlers onto the provided mux.
func (s *GatewayServer) RegisterRoutes(mux *http.ServeMux) { func (s *GatewayServer) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/v1/responses", s.handleResponses) mux.HandleFunc("/v1/responses", s.handleResponses)
mux.HandleFunc("/v1/models", s.handleModels)
}
func (s *GatewayServer) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
models := s.registry.Models()
var data []api.ModelInfo
for _, m := range models {
data = append(data, api.ModelInfo{
ID: m.Model,
Provider: m.Provider,
})
}
resp := api.ModelsResponse{
Object: "list",
Data: data,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
} }
func (s *GatewayServer) handleResponses(w http.ResponseWriter, r *http.Request) { func (s *GatewayServer) handleResponses(w http.ResponseWriter, r *http.Request) {
@@ -66,6 +91,9 @@ func (s *GatewayServer) handleResponses(w http.ResponseWriter, r *http.Request)
return return
} }
// Resolve provider_model_id (e.g., Azure deployment name) before sending to provider
fullReq.Model = s.registry.ResolveModelID(req.Model)
// Handle streaming vs non-streaming // Handle streaming vs non-streaming
if req.Stream { if req.Stream {
s.handleStreamingResponse(w, r, provider, &fullReq, &req) s.handleStreamingResponse(w, r, provider, &fullReq, &req)

View File

@@ -139,22 +139,25 @@ class ChatClient:
self.messages = [] self.messages = []
def print_models_table(): def print_models_table(base_url: str, headers: dict):
"""Print available models table.""" """Fetch and print available models from the gateway."""
console = Console()
try:
resp = httpx.get(f"{base_url}/v1/models", headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json().get("data", [])
except Exception as e:
console.print(f"[red]Failed to fetch models: {e}[/red]")
return
table = Table(title="Available Models", show_header=True, header_style="bold magenta") table = Table(title="Available Models", show_header=True, header_style="bold magenta")
table.add_column("Provider", style="cyan") table.add_column("Provider", style="cyan")
table.add_column("Model ID", style="green") table.add_column("Model ID", style="green")
table.add_column("Alias", style="yellow")
table.add_row("OpenAI", "gpt-4o", "gpt4") for model in data:
table.add_row("OpenAI", "gpt-4o-mini", "gpt4-mini") table.add_row(model.get("provider", ""), model.get("id", ""))
table.add_row("OpenAI", "o1", "o1")
table.add_row("Anthropic", "claude-3-5-sonnet-20241022", "claude")
table.add_row("Anthropic", "claude-3-5-haiku-20241022", "haiku")
table.add_row("Google", "gemini-2.0-flash-exp", "gemini")
table.add_row("Google", "gemini-1.5-pro", "gemini-pro")
Console().print(table) console.print(table)
def main(): def main():
@@ -227,7 +230,7 @@ def main():
)) ))
elif cmd == "/models": elif cmd == "/models":
print_models_table() print_models_table(args.url, client._headers())
elif cmd == "/model": elif cmd == "/model":
if len(cmd_parts) < 2: if len(cmd_parts) < 2: