Skip to content
Open
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
9 changes: 7 additions & 2 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ log:

server:
addr: "127.0.0.1:38440"
# Set a Bearer token for API authentication. When non-empty, all requests
# must include an Authorization: Bearer <token> header. If empty, auth is disabled.
# How incoming Bearer tokens are handled:
# "authentication" (default) — validate against auth_token below
# "transform" — forward the user's Bearer token as the provider's api_key
# auth_type: "authentication"
#
# When auth_type is "authentication", this Bearer token must match the
# request's Authorization header. Leave empty to disable auth entirely.
# auth_token: "replace-with-your-secret-token"

persistence:
Expand Down
12 changes: 11 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,19 @@ defaults:
```yaml
server:
addr: "127.0.0.1:38440" # 监听地址
auth_token: "" # Bearer 认证 Token(空 = 不认证)
auth_type: "authentication" # authentication(默认)| transform
auth_token: "" # Bearer 认证 Token(空 = 不认证;auth_type=transform 时忽略)
```

### auth_type

| 值 | 行为 |
|-----|------|
| `authentication`(默认) | 请求必须携带 `Authorization: Bearer <token>`,与 `auth_token` 比对。`auth_token` 为空时跳过认证。 |
| `transform` | 提取请求中的 `Bearer <token>`,替换掉 `providers.<provider>.api_key` 转发给上游供应商。无需配置 `auth_token`。 |

`transform` 模式适用于将 Moon Bridge 作为代理直接暴露给终端用户,由用户提供自己的 API Key 的场景。此时 `providers.<provider>.api_key` 可留空,用户的 token 会替代它在所有协议路径(Anthropic `x-api-key`、OpenAI Chat/Response `Bearer`、Google Gemini API key / Vertex AI `Bearer`)中生效。

## Models

模型定义包含上下文窗口、推理能力、扩展支持等元信息:
Expand Down
4 changes: 4 additions & 0 deletions docs/GETTING-STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ cp config.example.yml config.yml
mode: "Transform"
server:
addr: "127.0.0.1:38440"
# auth_type 默认为 "authentication"。auth_token 为空时不验证。
# 设为 "transform" 则转发用户的 Bearer token 给上游 Provider。
# auth_type: "authentication"
# auth_token: "your-secret-token"

defaults:
model: "deepseek-chat"
Expand Down
85 changes: 56 additions & 29 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"context"
"errors"
"fmt"
"moonbridge/internal/modelref"
Expand All @@ -20,8 +21,8 @@ const (
ProtocolAnthropic = "anthropic"
ProtocolOpenAIResponse = "openai-response"
// Phase 5: New protocol constants (D-08)
ProtocolGoogleGenAI = "google-genai"
ProtocolOpenAIChat = "openai-chat"
ProtocolGoogleGenAI = "google-genai"
ProtocolOpenAIChat = "openai-chat"
)

type Mode string
Expand All @@ -32,6 +33,31 @@ const (
ModeTransform Mode = "Transform"
)

// AuthType controls how the server handles incoming Bearer tokens.
type AuthType string

const (
AuthTypeAuthentication AuthType = "authentication"
AuthTypeTransform AuthType = "transform"
)

// contextKey is the type used for context keys in the config package.
type contextKey string

const transformAuthTokenKey contextKey = "transform_auth_token"

// WithTransformAuthToken stores a transformed auth token in the context.
func WithTransformAuthToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, transformAuthTokenKey, token)
}

// TransformAuthTokenFromContext retrieves the transformed auth token from the context.
// Returns the token and true if present.
func TransformAuthTokenFromContext(ctx context.Context) (string, bool) {
token, ok := ctx.Value(transformAuthTokenKey).(string)
return token, ok
}

type WebSearchSupport string

const (
Expand All @@ -51,22 +77,23 @@ type WebSearchConfig struct {
}

type Config struct {
Mode Mode
Addr string
AuthToken string
TraceRequests bool
LogLevel string
LogFormat string
SystemPrompt string
DefaultModel string
WebSearchSupport WebSearchSupport
WebSearchMaxUses int
TavilyAPIKey string
FirecrawlAPIKey string
SearchMaxRounds int
DefaultMaxTokens int
MaxSessions int `yaml:"max_sessions"` // 0 = unlimited
SessionTTL string `yaml:"session_ttl"` // default "24h"
Mode Mode
Addr string
AuthToken string
AuthType AuthType
TraceRequests bool
LogLevel string
LogFormat string
SystemPrompt string
DefaultModel string
WebSearchSupport WebSearchSupport
WebSearchMaxUses int
TavilyAPIKey string
FirecrawlAPIKey string
SearchMaxRounds int
DefaultMaxTokens int
MaxSessions int `yaml:"max_sessions"` // 0 = unlimited
SessionTTL string `yaml:"session_ttl"` // default "24h"
// Defaults holds the default configuration values.
Defaults Defaults
// Models is the canonical model definition map (shared across providers).
Expand Down Expand Up @@ -115,11 +142,11 @@ type RouteEntry struct {

// ProviderDef defines a single upstream provider.
type ProviderDef struct {
BaseURL string
APIKey string
Version string
UserAgent string
Protocol string // "anthropic" (default), "openai-response", "google-genai", or "openai-chat"
BaseURL string
APIKey string
Version string
UserAgent string
Protocol string // "anthropic" (default), "openai-response", "google-genai", or "openai-chat"
// Phase 5: Google GenAI flat fields (D-09).
// Only relevant when Protocol == ProtocolGoogleGenAI.
// project: Google Cloud project ID (Vertex AI).
Expand All @@ -129,7 +156,7 @@ type ProviderDef struct {
Location string `yaml:"location,omitempty"`
APIVersion string `yaml:"api_version,omitempty"`
// Cache config for this provider. If nil, provider does not use caching.
Cache *CacheConfig `yaml:"cache,omitempty"`
Cache *CacheConfig `yaml:"cache,omitempty"`
WebSearchSupport WebSearchSupport
WebSearchMaxUses int
TavilyAPIKey string
Expand Down Expand Up @@ -204,11 +231,11 @@ type ModelDef struct {

// OfferEntry declares that a provider offers a model defined in Models.
type OfferEntry struct {
Model string // references models.<slug>
UpstreamName string // optional, upstream model name (empty = same as slug)
Priority int // lower value = higher priority (0 is highest)
Model string // references models.<slug>
UpstreamName string // optional, upstream model name (empty = same as slug)
Priority int // lower value = higher priority (0 is highest)
Pricing ModelPricing
Overrides *ModelDef // optional provider-specific overrides
Overrides *ModelDef // optional provider-specific overrides
}

type ResponseProxyConfig struct {
Expand Down Expand Up @@ -285,7 +312,7 @@ func (cfg Config) validateTransform() error {
if def.BaseURL == "" {
return fmt.Errorf("providers.%s.base_url is required", key)
}
if def.APIKey == "" {
if cfg.AuthType != AuthTypeTransform && def.APIKey == "" {
return fmt.Errorf("providers.%s.api_key is required", key)
}
switch def.Protocol {
Expand Down
68 changes: 45 additions & 23 deletions internal/config/config_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ type TraceFileConfig struct {
}

type ServerFileConfig struct {
Addr string `yaml:"addr" json:"addr,omitempty"`
AuthToken string `yaml:"auth_token" json:"auth_token,omitempty"`
Addr string `yaml:"addr" json:"addr,omitempty"`
AuthToken string `yaml:"auth_token" json:"auth_token,omitempty"`
AuthType string `yaml:"auth_type" json:"auth_type,omitempty"`
MaxSessions int `yaml:"max_sessions"`
SessionTTL string `yaml:"session_ttl"`
}
Expand Down Expand Up @@ -122,33 +123,33 @@ type ModelDefFileConfig struct {
}

type OfferFileConfig struct {
Model string `yaml:"model" json:"model"`
UpstreamName string `yaml:"upstream_name,omitempty" json:"upstream_name,omitempty"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
Pricing ModelPricingFileConfig `yaml:"pricing,omitempty" json:"pricing,omitempty"`
Overrides *ModelDefFileConfig `yaml:"overrides,omitempty" json:"overrides,omitempty"`
Model string `yaml:"model" json:"model"`
UpstreamName string `yaml:"upstream_name,omitempty" json:"upstream_name,omitempty"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
Pricing ModelPricingFileConfig `yaml:"pricing,omitempty" json:"pricing,omitempty"`
Overrides *ModelDefFileConfig `yaml:"overrides,omitempty" json:"overrides,omitempty"`
}

type ProviderDefFileConfig struct {
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
UserAgent string `yaml:"user_agent,omitempty" json:"user_agent,omitempty"`
Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"`
WebSearch WebSearchFileConfig `yaml:"web_search,omitempty" json:"web_search,omitempty"`
Extensions map[string]ExtensionFileConfig `yaml:"extensions,omitempty" json:"extensions,omitempty"`
Offers []OfferFileConfig `yaml:"offers,omitempty" json:"offers,omitempty"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
UserAgent string `yaml:"user_agent,omitempty" json:"user_agent,omitempty"`
Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"`
WebSearch WebSearchFileConfig `yaml:"web_search,omitempty" json:"web_search,omitempty"`
Extensions map[string]ExtensionFileConfig `yaml:"extensions,omitempty" json:"extensions,omitempty"`
Offers []OfferFileConfig `yaml:"offers,omitempty" json:"offers,omitempty"`
}

type RouteFileConfig struct {
To string `yaml:"to,omitempty" json:"to,omitempty"` // backward compat "provider/model"
Model string `yaml:"model,omitempty" json:"model,omitempty"`
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
ContextWindow int `yaml:"context_window,omitempty" json:"context_window,omitempty"`
WebSearch WebSearchFileConfig `yaml:"web_search,omitempty" json:"web_search,omitempty"`
Extensions map[string]ExtensionFileConfig `yaml:"extensions,omitempty" json:"extensions,omitempty"`
To string `yaml:"to,omitempty" json:"to,omitempty"` // backward compat "provider/model"
Model string `yaml:"model,omitempty" json:"model,omitempty"`
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
ContextWindow int `yaml:"context_window,omitempty" json:"context_window,omitempty"`
WebSearch WebSearchFileConfig `yaml:"web_search,omitempty" json:"web_search,omitempty"`
Extensions map[string]ExtensionFileConfig `yaml:"extensions,omitempty" json:"extensions,omitempty"`
}

func (cfg *RouteFileConfig) UnmarshalYAML(value *yaml.Node) error {
Expand Down Expand Up @@ -348,10 +349,16 @@ func FromFileConfigWithOptions(fileConfig FileConfig, opts LoadOptions) (Config,
responseProxy := FromResponseProxyFileConfig(fileConfig.Proxy.Response)
anthropicProxy := FromAnthropicProxyFileConfig(fileConfig.Proxy.Anthropic)

authType, err := parseAuthType(fileConfig.Server.AuthType)
if err != nil {
return Config{}, err
}

cfg := Config{
Mode: mode,
Addr: valueOrDefault(strings.TrimSpace(fileConfig.Server.Addr), DefaultAddr),
AuthToken: strings.TrimSpace(fileConfig.Server.AuthToken),
AuthType: authType,
MaxSessions: intOrDefault(fileConfig.Server.MaxSessions, 0),
SessionTTL: valueOrDefault(strings.TrimSpace(fileConfig.Server.SessionTTL), "24h"),
TraceRequests: traceEnabled,
Expand Down Expand Up @@ -786,6 +793,21 @@ func parseMode(value string) (Mode, error) {
}
}

func parseAuthType(value string) (AuthType, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return AuthTypeAuthentication, nil
}
switch AuthType(trimmed) {
case AuthTypeTransform:
return AuthTypeTransform, nil
case AuthTypeAuthentication:
return AuthTypeAuthentication, nil
default:
return "", fmt.Errorf("invalid auth_type %q (expected \"authentication\" or \"transform\")", trimmed)
}
}

func parseWebSearchSupport(value string) (WebSearchSupport, error) {
switch support := WebSearchSupport(strings.TrimSpace(value)); support {
case "":
Expand Down
1 change: 1 addition & 0 deletions internal/config/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func (cfg Config) ToFileConfig() FileConfig {
Server: ServerFileConfig{
Addr: cfg.Addr,
AuthToken: cfg.AuthToken,
AuthType: string(cfg.AuthType),
MaxSessions: cfg.MaxSessions,
SessionTTL: cfg.SessionTTL,
},
Expand Down
2 changes: 2 additions & 0 deletions internal/config/domain_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package config
type ServerConfig struct {
Addr string
AuthToken string
AuthType AuthType
Mode string
MaxSessions int
SessionTTL string
Expand All @@ -15,6 +16,7 @@ func ServerFromGlobalConfig(cfg *Config) ServerConfig {
return ServerConfig{
Addr: cfg.Addr,
AuthToken: cfg.AuthToken,
AuthType: cfg.AuthType,
Mode: string(cfg.Mode),
MaxSessions: cfg.MaxSessions,
SessionTTL: cfg.SessionTTL,
Expand Down
13 changes: 12 additions & 1 deletion internal/protocol/anthropic/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"log/slog"
"net/http"
"strings"

"moonbridge/internal/config"
)

type ClientConfig struct {
Expand Down Expand Up @@ -205,7 +207,7 @@ func (client *Client) newRequest(ctx context.Context, messageRequest MessageRequ
return nil, err
}
httpRequest.Header.Set("content-type", "application/json")
httpRequest.Header.Set("x-api-key", client.apiKey)
httpRequest.Header.Set("x-api-key", client.effectiveAPIKey(ctx))
if client.version != "" {
httpRequest.Header.Set("anthropic-version", client.version)
}
Expand Down Expand Up @@ -375,3 +377,12 @@ func (err *ProviderError) OpenAIType() string {
func UnsupportedStreamEvent(event string) error {
return fmt.Errorf("unsupported stream event %q", event)
}

// effectiveAPIKey returns the transformed auth token from context if available,
// otherwise falls back to the client's configured API key.
func (client *Client) effectiveAPIKey(ctx context.Context) string {
if token, ok := config.TransformAuthTokenFromContext(ctx); ok {
return token
}
return client.apiKey
}
13 changes: 12 additions & 1 deletion internal/protocol/chat/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"log/slog"
"net/http"
"strings"

"moonbridge/internal/config"
)

// ClientConfig configures the OpenAI Chat Completions HTTP client.
Expand Down Expand Up @@ -162,7 +164,7 @@ func (c *Client) newRequest(ctx context.Context, req *ChatRequest) (*http.Reques
return nil, fmt.Errorf("chat API request build: %w", err)
}
httpReq.Header.Set("content-type", "application/json")
httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("authorization", "Bearer "+c.effectiveAPIKey(ctx))
if c.userAgent != "" {
Comment thread
Gu-ZT marked this conversation as resolved.
httpReq.Header.Set("user-agent", c.userAgent)
}
Expand Down Expand Up @@ -226,3 +228,12 @@ func safeUsage(u *Usage) Usage {
}
return *u
}

// effectiveAPIKey returns the transformed auth token from context if available,
// otherwise falls back to the client's configured API key.
func (c *Client) effectiveAPIKey(ctx context.Context) string {
if token, ok := config.TransformAuthTokenFromContext(ctx); ok {
return token
}
return c.apiKey
}
Loading