mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 04:20:50 +08:00
474 lines
17 KiB
Go
474 lines
17 KiB
Go
// Package config provides configuration management for the CLI Proxy API server.
|
|
// It handles loading and parsing YAML configuration files, and provides structured
|
|
// access to application settings including server port, authentication directory,
|
|
// debug settings, proxy configuration, and API keys.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Config represents the application's configuration, loaded from a YAML file.
|
|
type Config struct {
|
|
// Port is the network port on which the API server will listen.
|
|
Port int `yaml:"port" json:"-"`
|
|
|
|
// AuthDir is the directory where authentication token files are stored.
|
|
AuthDir string `yaml:"auth-dir" json:"-"`
|
|
|
|
// Debug enables or disables debug-level logging and other debug features.
|
|
Debug bool `yaml:"debug" json:"debug"`
|
|
|
|
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
|
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
|
|
|
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
|
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
|
|
|
// QuotaExceeded defines the behavior when a quota is exceeded.
|
|
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
|
|
|
|
// GlAPIKey is the API key for the generative language API.
|
|
GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"`
|
|
|
|
// RequestLog enables or disables detailed request logging functionality.
|
|
RequestLog bool `yaml:"request-log" json:"request-log"`
|
|
|
|
// RequestRetry defines the retry times when the request failed.
|
|
RequestRetry int `yaml:"request-retry" json:"request-retry"`
|
|
|
|
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
|
|
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
|
|
|
|
// ForceGPT5Codex forces the use of GPT-5 Codex model.
|
|
ForceGPT5Codex bool `yaml:"force-gpt-5-codex" json:"force-gpt-5-codex"`
|
|
|
|
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
|
|
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
|
|
|
|
// OpenAICompatibility defines OpenAI API compatibility configurations for external providers.
|
|
OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"`
|
|
|
|
// AllowLocalhostUnauthenticated allows unauthenticated requests from localhost.
|
|
AllowLocalhostUnauthenticated bool `yaml:"allow-localhost-unauthenticated" json:"allow-localhost-unauthenticated"`
|
|
|
|
// RemoteManagement nests management-related options under 'remote-management'.
|
|
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
|
|
|
|
// GeminiWeb groups configuration for Gemini Web client
|
|
GeminiWeb GeminiWebConfig `yaml:"gemini-web" json:"gemini-web"`
|
|
}
|
|
|
|
// GeminiWebConfig nests Gemini Web related options under 'gemini-web'.
|
|
type GeminiWebConfig struct {
|
|
// Context enables JSON-based conversation reuse.
|
|
// Defaults to true if not set in YAML (see LoadConfig).
|
|
Context bool `yaml:"context" json:"context"`
|
|
|
|
// CodeMode, when true, enables coding mode behaviors for Gemini Web:
|
|
// - Attach the predefined "Coding partner" Gem
|
|
// - Enable XML wrapping hint for tool markup
|
|
// - Merge <think> content into visible content for tool-friendly output
|
|
CodeMode bool `yaml:"code-mode" json:"code-mode"`
|
|
|
|
// MaxCharsPerRequest caps the number of characters (runes) sent to
|
|
// Gemini Web in a single request. Long prompts will be split into
|
|
// multiple requests with a continuation hint, and only the final
|
|
// request will carry any files. When unset or <=0, a conservative
|
|
// default of 1,000,000 will be used.
|
|
MaxCharsPerRequest int `yaml:"max-chars-per-request" json:"max-chars-per-request"`
|
|
|
|
// DisableContinuationHint, when true, disables the continuation hint for split prompts.
|
|
// The hint is enabled by default.
|
|
DisableContinuationHint bool `yaml:"disable-continuation-hint,omitempty" json:"disable-continuation-hint,omitempty"`
|
|
|
|
// TokenRefreshSeconds controls the background cookie auto-refresh interval in seconds.
|
|
// When unset or <= 0, defaults to 540 seconds.
|
|
TokenRefreshSeconds int `yaml:"token-refresh-seconds" json:"token-refresh-seconds"`
|
|
}
|
|
|
|
// RemoteManagement holds management API configuration under 'remote-management'.
|
|
type RemoteManagement struct {
|
|
// AllowRemote toggles remote (non-localhost) access to management API.
|
|
AllowRemote bool `yaml:"allow-remote"`
|
|
// SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'.
|
|
SecretKey string `yaml:"secret-key"`
|
|
}
|
|
|
|
// QuotaExceeded defines the behavior when API quota limits are exceeded.
|
|
// It provides configuration options for automatic failover mechanisms.
|
|
type QuotaExceeded struct {
|
|
// SwitchProject indicates whether to automatically switch to another project when a quota is exceeded.
|
|
SwitchProject bool `yaml:"switch-project" json:"switch-project"`
|
|
|
|
// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.
|
|
SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"`
|
|
}
|
|
|
|
// ClaudeKey represents the configuration for a Claude API key,
|
|
// including the API key itself and an optional base URL for the API endpoint.
|
|
type ClaudeKey struct {
|
|
// APIKey is the authentication key for accessing Claude API services.
|
|
APIKey string `yaml:"api-key" json:"api-key"`
|
|
|
|
// BaseURL is the base URL for the Claude API endpoint.
|
|
// If empty, the default Claude API URL will be used.
|
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
|
}
|
|
|
|
// CodexKey represents the configuration for a Codex API key,
|
|
// including the API key itself and an optional base URL for the API endpoint.
|
|
type CodexKey struct {
|
|
// APIKey is the authentication key for accessing Codex API services.
|
|
APIKey string `yaml:"api-key" json:"api-key"`
|
|
|
|
// BaseURL is the base URL for the Codex API endpoint.
|
|
// If empty, the default Codex API URL will be used.
|
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
|
}
|
|
|
|
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
|
// with external providers, allowing model aliases to be routed through OpenAI API format.
|
|
type OpenAICompatibility struct {
|
|
// Name is the identifier for this OpenAI compatibility configuration.
|
|
Name string `yaml:"name" json:"name"`
|
|
|
|
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
|
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
|
|
|
// APIKeys are the authentication keys for accessing the external API services.
|
|
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
|
|
|
// Models defines the model configurations including aliases for routing.
|
|
Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
|
|
}
|
|
|
|
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
|
|
// including the actual model name and its alias for API routing.
|
|
type OpenAICompatibilityModel struct {
|
|
// Name is the actual model name used by the external provider.
|
|
Name string `yaml:"name" json:"name"`
|
|
|
|
// Alias is the model name alias that clients will use to reference this model.
|
|
Alias string `yaml:"alias" json:"alias"`
|
|
}
|
|
|
|
// LoadConfig reads a YAML configuration file from the given path,
|
|
// unmarshals it into a Config struct, applies environment variable overrides,
|
|
// and returns it.
|
|
//
|
|
// Parameters:
|
|
// - configFile: The path to the YAML configuration file
|
|
//
|
|
// Returns:
|
|
// - *Config: The loaded configuration
|
|
// - error: An error if the configuration could not be loaded
|
|
func LoadConfig(configFile string) (*Config, error) {
|
|
// Read the entire configuration file into memory.
|
|
data, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
// Unmarshal the YAML data into the Config struct.
|
|
var config Config
|
|
// Set defaults before unmarshal so that absent keys keep defaults.
|
|
config.GeminiWeb.Context = true
|
|
if err = yaml.Unmarshal(data, &config); err != nil {
|
|
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
|
}
|
|
|
|
// Hash remote management key if plaintext is detected (nested)
|
|
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
|
|
if config.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(config.RemoteManagement.SecretKey) {
|
|
hashed, errHash := hashSecret(config.RemoteManagement.SecretKey)
|
|
if errHash != nil {
|
|
return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
|
|
}
|
|
config.RemoteManagement.SecretKey = hashed
|
|
|
|
// Persist the hashed value back to the config file to avoid re-hashing on next startup.
|
|
// Preserve YAML comments and ordering; update only the nested key.
|
|
_ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed)
|
|
}
|
|
|
|
// Return the populated configuration struct.
|
|
return &config, nil
|
|
}
|
|
|
|
// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
|
|
func looksLikeBcrypt(s string) bool {
|
|
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
|
|
}
|
|
|
|
// hashSecret hashes the given secret using bcrypt.
|
|
func hashSecret(secret string) (string, error) {
|
|
// Use default cost for simplicity.
|
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(hashedBytes), nil
|
|
}
|
|
|
|
// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
|
|
// and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
|
|
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
|
// Load original YAML as a node tree to preserve comments and ordering.
|
|
data, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var original yaml.Node
|
|
if err = yaml.Unmarshal(data, &original); err != nil {
|
|
return err
|
|
}
|
|
if original.Kind != yaml.DocumentNode || len(original.Content) == 0 {
|
|
return fmt.Errorf("invalid yaml document structure")
|
|
}
|
|
if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode {
|
|
return fmt.Errorf("expected root mapping node")
|
|
}
|
|
|
|
// Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.
|
|
rendered, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var generated yaml.Node
|
|
if err = yaml.Unmarshal(rendered, &generated); err != nil {
|
|
return err
|
|
}
|
|
if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil {
|
|
return fmt.Errorf("invalid generated yaml structure")
|
|
}
|
|
if generated.Content[0].Kind != yaml.MappingNode {
|
|
return fmt.Errorf("expected generated root mapping node")
|
|
}
|
|
|
|
// Merge generated into original in-place, preserving comments/order of existing nodes.
|
|
mergeMappingPreserve(original.Content[0], generated.Content[0])
|
|
|
|
// Write back.
|
|
f, err := os.Create(configFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
enc := yaml.NewEncoder(f)
|
|
enc.SetIndent(2)
|
|
if err = enc.Encode(&original); err != nil {
|
|
_ = enc.Close()
|
|
return err
|
|
}
|
|
return enc.Close()
|
|
}
|
|
|
|
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
|
|
// while preserving comments and positions.
|
|
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
|
|
data, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var root yaml.Node
|
|
if err = yaml.Unmarshal(data, &root); err != nil {
|
|
return err
|
|
}
|
|
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
|
|
return fmt.Errorf("invalid yaml document structure")
|
|
}
|
|
node := root.Content[0]
|
|
// descend mapping nodes following path
|
|
for i, key := range path {
|
|
if i == len(path)-1 {
|
|
// set final scalar
|
|
v := getOrCreateMapValue(node, key)
|
|
v.Kind = yaml.ScalarNode
|
|
v.Tag = "!!str"
|
|
v.Value = value
|
|
} else {
|
|
next := getOrCreateMapValue(node, key)
|
|
if next.Kind != yaml.MappingNode {
|
|
next.Kind = yaml.MappingNode
|
|
next.Tag = "!!map"
|
|
}
|
|
node = next
|
|
}
|
|
}
|
|
f, err := os.Create(configFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
enc := yaml.NewEncoder(f)
|
|
enc.SetIndent(2)
|
|
if err = enc.Encode(&root); err != nil {
|
|
_ = enc.Close()
|
|
return err
|
|
}
|
|
return enc.Close()
|
|
}
|
|
|
|
// getOrCreateMapValue finds the value node for a given key in a mapping node.
|
|
// If not found, it appends a new key/value pair and returns the new value node.
|
|
func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
|
|
if mapNode.Kind != yaml.MappingNode {
|
|
mapNode.Kind = yaml.MappingNode
|
|
mapNode.Tag = "!!map"
|
|
mapNode.Content = nil
|
|
}
|
|
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
|
k := mapNode.Content[i]
|
|
if k.Value == key {
|
|
return mapNode.Content[i+1]
|
|
}
|
|
}
|
|
// append new key/value
|
|
mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key})
|
|
val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}
|
|
mapNode.Content = append(mapNode.Content, val)
|
|
return val
|
|
}
|
|
|
|
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
|
|
// key order and comments of existing keys in dst. Unknown keys from src are appended
|
|
// to dst at the end, copying their node structure from src.
|
|
func mergeMappingPreserve(dst, src *yaml.Node) {
|
|
if dst == nil || src == nil {
|
|
return
|
|
}
|
|
if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode {
|
|
// If kinds do not match, prefer replacing dst with src semantics in-place
|
|
// but keep dst node object to preserve any attached comments at the parent level.
|
|
copyNodeShallow(dst, src)
|
|
return
|
|
}
|
|
// Build a lookup of existing keys in dst
|
|
for i := 0; i+1 < len(src.Content); i += 2 {
|
|
sk := src.Content[i]
|
|
sv := src.Content[i+1]
|
|
idx := findMapKeyIndex(dst, sk.Value)
|
|
if idx >= 0 {
|
|
// Merge into existing value node
|
|
dv := dst.Content[idx+1]
|
|
mergeNodePreserve(dv, sv)
|
|
} else {
|
|
// Append new key/value pair by deep-copying from src
|
|
dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
|
|
}
|
|
}
|
|
}
|
|
|
|
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
|
|
// reusing destination nodes to keep comments and anchors. For sequences, it updates
|
|
// in-place by index.
|
|
func mergeNodePreserve(dst, src *yaml.Node) {
|
|
if dst == nil || src == nil {
|
|
return
|
|
}
|
|
switch src.Kind {
|
|
case yaml.MappingNode:
|
|
if dst.Kind != yaml.MappingNode {
|
|
copyNodeShallow(dst, src)
|
|
}
|
|
mergeMappingPreserve(dst, src)
|
|
case yaml.SequenceNode:
|
|
// Preserve explicit null style if dst was null and src is empty sequence
|
|
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
|
|
// Keep as null to preserve original style
|
|
return
|
|
}
|
|
if dst.Kind != yaml.SequenceNode {
|
|
dst.Kind = yaml.SequenceNode
|
|
dst.Tag = "!!seq"
|
|
dst.Content = nil
|
|
}
|
|
// Update elements in place
|
|
minContent := len(dst.Content)
|
|
if len(src.Content) < minContent {
|
|
minContent = len(src.Content)
|
|
}
|
|
for i := 0; i < minContent; i++ {
|
|
if dst.Content[i] == nil {
|
|
dst.Content[i] = deepCopyNode(src.Content[i])
|
|
continue
|
|
}
|
|
mergeNodePreserve(dst.Content[i], src.Content[i])
|
|
}
|
|
// Append any extra items from src
|
|
for i := len(dst.Content); i < len(src.Content); i++ {
|
|
dst.Content = append(dst.Content, deepCopyNode(src.Content[i]))
|
|
}
|
|
// Truncate if dst has extra items not in src
|
|
if len(src.Content) < len(dst.Content) {
|
|
dst.Content = dst.Content[:len(src.Content)]
|
|
}
|
|
case yaml.ScalarNode, yaml.AliasNode:
|
|
// For scalars, update Tag and Value but keep Style from dst to preserve quoting
|
|
dst.Kind = src.Kind
|
|
dst.Tag = src.Tag
|
|
dst.Value = src.Value
|
|
// Keep dst.Style as-is intentionally
|
|
case 0:
|
|
// Unknown/empty kind; do nothing
|
|
default:
|
|
// Fallback: replace shallowly
|
|
copyNodeShallow(dst, src)
|
|
}
|
|
}
|
|
|
|
// findMapKeyIndex returns the index of key node in dst mapping (index of key, not value).
|
|
// Returns -1 when not found.
|
|
func findMapKeyIndex(mapNode *yaml.Node, key string) int {
|
|
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
|
|
return -1
|
|
}
|
|
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
|
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// deepCopyNode creates a deep copy of a yaml.Node graph.
|
|
func deepCopyNode(n *yaml.Node) *yaml.Node {
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
cp := *n
|
|
if len(n.Content) > 0 {
|
|
cp.Content = make([]*yaml.Node, len(n.Content))
|
|
for i := range n.Content {
|
|
cp.Content[i] = deepCopyNode(n.Content[i])
|
|
}
|
|
}
|
|
return &cp
|
|
}
|
|
|
|
// copyNodeShallow copies type/tag/value and resets content to match src, but
|
|
// keeps the same destination node pointer to preserve parent relations/comments.
|
|
func copyNodeShallow(dst, src *yaml.Node) {
|
|
if dst == nil || src == nil {
|
|
return
|
|
}
|
|
dst.Kind = src.Kind
|
|
dst.Tag = src.Tag
|
|
dst.Value = src.Value
|
|
// Replace content with deep copy from src
|
|
if len(src.Content) > 0 {
|
|
dst.Content = make([]*yaml.Node, len(src.Content))
|
|
for i := range src.Content {
|
|
dst.Content[i] = deepCopyNode(src.Content[i])
|
|
}
|
|
} else {
|
|
dst.Content = nil
|
|
}
|
|
}
|