diff --git a/README.md b/README.md index 16967c0d..cf582685 100644 --- a/README.md +++ b/README.md @@ -285,19 +285,24 @@ The server uses a YAML configuration file (`config.yaml`) located in the project | `usage-statistics-enabled` | boolean | true | Enable in-memory usage aggregation for management APIs. Disable to drop all collected usage metrics. | | `api-keys` | string[] | [] | Legacy shorthand for inline API keys. Values are mirrored into the `config-api-key` provider for backwards compatibility. | | `generative-language-api-key` | string[] | [] | List of Generative Language API keys. | -| `codex-api-key` | object | {} | List of Codex API keys. | -| `codex-api-key.api-key` | string | "" | Codex API key. | -| `codex-api-key.base-url` | string | "" | Custom Codex API endpoint, if you use a third-party API endpoint. | -| `claude-api-key` | object | {} | List of Claude API keys. | -| `claude-api-key.api-key` | string | "" | Claude API key. | -| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. | -| `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). | -| `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. | -| `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. | -| `openai-compatibility.*.api-keys` | string[] | [] | The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed. | -| `openai-compatibility.*.models` | object[] | [] | The actual model name. | -| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. | -| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. | +| `codex-api-key` | object | {} | List of Codex API keys. | +| `codex-api-key.api-key` | string | "" | Codex API key. | +| `codex-api-key.base-url` | string | "" | Custom Codex API endpoint, if you use a third-party API endpoint. | +| `codex-api-key.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. | +| `claude-api-key` | object | {} | List of Claude API keys. | +| `claude-api-key.api-key` | string | "" | Claude API key. | +| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. | +| `claude-api-key.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. | +| `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). | +| `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. | +| `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. | +| `openai-compatibility.*.api-keys` | string[] | [] | (Deprecated) The API keys for the provider. Use api-key-entries instead for per-key proxy support. | +| `openai-compatibility.*.api-key-entries` | object[] | [] | API key entries with optional per-key proxy configuration. Preferred over api-keys. | +| `openai-compatibility.*.api-key-entries.*.api-key` | string | "" | The API key for this entry. | +| `openai-compatibility.*.api-key-entries.*.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. | +| `openai-compatibility.*.models` | object[] | [] | The actual model name. | +| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. | +| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. | | `gemini-web` | object | {} | Configuration specific to the Gemini Web client. | | `gemini-web.context` | boolean | true | Enables conversation context reuse for continuous dialogue. | | `gemini-web.code-mode` | boolean | false | Enables code mode for optimized responses in coding-related tasks. | @@ -361,20 +366,28 @@ generative-language-api-key: codex-api-key: - api-key: "sk-atSM..." base-url: "https://www.example.com" # use the custom codex API endpoint - + proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override + # Claude API keys claude-api-key: - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url - api-key: "sk-atSM..." base-url: "https://www.example.com" # use the custom claude API endpoint + proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override # OpenAI compatibility providers openai-compatibility: - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. - api-keys: # The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed. - - "sk-or-v1-...b780" - - "sk-or-v1-...b781" + # New format with per-key proxy support (recommended): + api-key-entries: + - api-key: "sk-or-v1-...b780" + proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override + - api-key: "sk-or-v1-...b781" # without proxy-url + # Legacy format (still supported, but cannot specify proxy per key): + # api-keys: + # - "sk-or-v1-...b780" + # - "sk-or-v1-...b781" models: # The models supported by the provider. - name: "moonshotai/kimi-k2:free" # The actual model name. alias: "kimi-k2" # The alias used in the API. @@ -386,10 +399,26 @@ Configure upstream OpenAI-compatible providers (e.g., OpenRouter) via `openai-co - name: provider identifier used internally - base-url: provider base URL -- api-keys: optional list of API keys (omit if provider allows unauthenticated requests) +- api-key-entries: list of API key entries with optional per-key proxy configuration (recommended) +- api-keys: (deprecated) simple list of API keys without proxy support - models: list of mappings from upstream model `name` to local `alias` -Example: +Example with per-key proxy support: + +```yaml +openai-compatibility: + - name: "openrouter" + base-url: "https://openrouter.ai/api/v1" + api-key-entries: + - api-key: "sk-or-v1-...b780" + proxy-url: "socks5://proxy.example.com:1080" + - api-key: "sk-or-v1-...b781" + models: + - name: "moonshotai/kimi-k2:free" + alias: "kimi-k2" +``` + +Legacy format (still supported): ```yaml openai-compatibility: diff --git a/README_CN.md b/README_CN.md index a234797a..399fea2f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -297,19 +297,24 @@ console.log(await claudeResponse.json()); | `usage-statistics-enabled` | boolean | true | 是否启用内存中的使用统计;设为 false 时直接丢弃所有统计数据。 | | `api-keys` | string[] | [] | 兼容旧配置的简写,会自动同步到默认 `config-api-key` 提供方。 | | `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 | -| `codex-api-key` | object | {} | Codex API密钥列表。 | -| `codex-api-key.api-key` | string | "" | Codex API密钥。 | -| `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 | -| `claude-api-key` | object | {} | Claude API密钥列表。 | -| `claude-api-key.api-key` | string | "" | Claude API密钥。 | -| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点,如果您使用第三方的API端点。 | -| `openai-compatibility` | object[] | [] | 上游OpenAI兼容提供商的配置(名称、基础URL、API密钥、模型)。 | -| `openai-compatibility.*.name` | string | "" | 提供商的名称。它将被用于用户代理(User Agent)和其他地方。 | -| `openai-compatibility.*.base-url` | string | "" | 提供商的基础URL。 | -| `openai-compatibility.*.api-keys` | string[] | [] | 提供商的API密钥。如果需要,可以添加多个密钥。如果允许未经身份验证的访问,则可以省略。 | -| `openai-compatibility.*.models` | object[] | [] | 实际的模型名称。 | -| `openai-compatibility.*.models.*.name` | string | "" | 提供商支持的模型。 | -| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 | +| `codex-api-key` | object | {} | Codex API密钥列表。 | +| `codex-api-key.api-key` | string | "" | Codex API密钥。 | +| `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 | +| `codex-api-key.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 | +| `claude-api-key` | object | {} | Claude API密钥列表。 | +| `claude-api-key.api-key` | string | "" | Claude API密钥。 | +| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点,如果您使用第三方的API端点。 | +| `claude-api-key.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 | +| `openai-compatibility` | object[] | [] | 上游OpenAI兼容提供商的配置(名称、基础URL、API密钥、模型)。 | +| `openai-compatibility.*.name` | string | "" | 提供商的名称。它将被用于用户代理(User Agent)和其他地方。 | +| `openai-compatibility.*.base-url` | string | "" | 提供商的基础URL。 | +| `openai-compatibility.*.api-keys` | string[] | [] | (已弃用) 提供商的API密钥。建议改用api-key-entries以获得每密钥代理支持。 | +| `openai-compatibility.*.api-key-entries` | object[] | [] | API密钥条目,支持可选的每密钥代理配置。优先于api-keys。 | +| `openai-compatibility.*.api-key-entries.*.api-key` | string | "" | 该条目的API密钥。 | +| `openai-compatibility.*.api-key-entries.*.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 | +| `openai-compatibility.*.models` | object[] | [] | 实际的模型名称。 | +| `openai-compatibility.*.models.*.name` | string | "" | 提供商支持的模型。 | +| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 | | `gemini-web` | object | {} | Gemini Web 客户端的特定配置。 | | `gemini-web.context` | boolean | true | 是否启用会话上下文重用,以实现连续对话。 | | `gemini-web.code-mode` | boolean | false | 是否启用代码模式,优化代码相关任务的响应。 | @@ -373,20 +378,28 @@ generative-language-api-key: codex-api-key: - api-key: "sk-atSM..." base-url: "https://www.example.com" # 第三方 Codex API 中转服务端点 + proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置 # Claude API 密钥 claude-api-key: - - api-key: "sk-atSM..." # 如果使用官方 Claude API,无需设置 base-url + - api-key: "sk-atSM..." # 如果使用官方 Claude API,无需设置 base-url - api-key: "sk-atSM..." base-url: "https://www.example.com" # 第三方 Claude API 中转服务端点 + proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置 # OpenAI 兼容提供商 openai-compatibility: - name: "openrouter" # 提供商的名称;它将被用于用户代理和其它地方。 base-url: "https://openrouter.ai/api/v1" # 提供商的基础URL。 - api-keys: # 提供商的API密钥。如果需要,可以添加多个密钥。如果允许未经身份验证的访问,则可以省略。 - - "sk-or-v1-...b780" - - "sk-or-v1-...b781" + # 新格式:支持每密钥代理配置(推荐): + api-key-entries: + - api-key: "sk-or-v1-...b780" + proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置 + - api-key: "sk-or-v1-...b781" # 不进行额外代理设置 + # 旧格式(仍支持,但无法为每个密钥指定代理): + # api-keys: + # - "sk-or-v1-...b780" + # - "sk-or-v1-...b781" models: # 提供商支持的模型。 - name: "moonshotai/kimi-k2:free" # 实际的模型名称。 alias: "kimi-k2" # 在API中使用的别名。 @@ -398,10 +411,26 @@ openai-compatibility: - name:内部识别名 - base-url:提供商基础地址 -- api-keys:可选,多密钥轮询(若提供商支持无鉴权可省略) +- api-key-entries:API密钥条目列表,支持可选的每密钥代理配置(推荐) +- api-keys:(已弃用) 简单的API密钥列表,不支持代理配置 - models:将上游模型 `name` 映射为本地可用 `alias` -示例: +支持每密钥代理配置的示例: + +```yaml +openai-compatibility: + - name: "openrouter" + base-url: "https://openrouter.ai/api/v1" + api-key-entries: + - api-key: "sk-or-v1-...b780" + proxy-url: "socks5://proxy.example.com:1080" + - api-key: "sk-or-v1-...b781" + models: + - name: "moonshotai/kimi-k2:free" + alias: "kimi-k2" +``` + +旧格式(仍支持): ```yaml openai-compatibility: diff --git a/config.example.yaml b/config.example.yaml index 5f64a03e..bbe0b415 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -51,20 +51,28 @@ quota-exceeded: #codex-api-key: # - api-key: "sk-atSM..." # base-url: "https://www.example.com" # use the custom codex API endpoint +# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override # Claude API keys #claude-api-key: # - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url # - api-key: "sk-atSM..." # base-url: "https://www.example.com" # use the custom claude API endpoint +# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override # OpenAI compatibility providers #openai-compatibility: # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. # base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. -# api-keys: # The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed. -# - "sk-or-v1-...b780" -# - "sk-or-v1-...b781" +# # New format with per-key proxy support (recommended): +# api-key-entries: +# - api-key: "sk-or-v1-...b780" +# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override +# - api-key: "sk-or-v1-...b781" # without proxy-url +# # Legacy format (still supported, but cannot specify proxy per key): +# # api-keys: +# # - "sk-or-v1-...b780" +# # - "sk-or-v1-...b781" # models: # The models supported by the provider. # - name: "moonshotai/kimi-k2:free" # The actual model name. # alias: "kimi-k2" # The alias used in the API. diff --git a/internal/config/config.go b/internal/config/config.go index f9d05e3b..de1d49eb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -107,6 +107,9 @@ type ClaudeKey struct { // 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"` + + // ProxyURL overrides the global proxy setting for this API key if provided. + ProxyURL string `yaml:"proxy-url" json:"proxy-url"` } // CodexKey represents the configuration for a Codex API key, @@ -118,6 +121,9 @@ type CodexKey struct { // 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"` + + // ProxyURL overrides the global proxy setting for this API key if provided. + ProxyURL string `yaml:"proxy-url" json:"proxy-url"` } // OpenAICompatibility represents the configuration for OpenAI API compatibility @@ -130,12 +136,25 @@ type OpenAICompatibility struct { 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"` + // Deprecated: Use APIKeyEntries instead to support per-key proxy configuration. + APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"` + + // APIKeyEntries defines API keys with optional per-key proxy configuration. + APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"` // Models defines the model configurations including aliases for routing. Models []OpenAICompatibilityModel `yaml:"models" json:"models"` } +// OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting. +type OpenAICompatibilityAPIKey struct { + // APIKey is the authentication key for accessing the external API services. + APIKey string `yaml:"api-key" json:"api-key"` + + // ProxyURL overrides the global proxy setting for this API key if provided. + ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"` +} + // OpenAICompatibilityModel represents a model configuration for OpenAI compatibility, // including the actual model name and its alias for API routing. type OpenAICompatibilityModel struct { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 1a2f720b..cf04e2bd 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -746,11 +746,13 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if ck.BaseURL != "" { attrs["base_url"] = ck.BaseURL } + proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ ID: id, Provider: "claude", Label: "claude-apikey", Status: coreauth.StatusActive, + ProxyURL: proxyURL, Attributes: attrs, CreatedAt: now, UpdatedAt: now, @@ -772,11 +774,13 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if ck.BaseURL != "" { attrs["base_url"] = ck.BaseURL } + proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ ID: id, Provider: "codex", Label: "codex-apikey", Status: coreauth.StatusActive, + ProxyURL: proxyURL, Attributes: attrs, CreatedAt: now, UpdatedAt: now, @@ -790,33 +794,70 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { providerName = "openai-compatibility" } base := strings.TrimSpace(compat.BaseURL) - for j := range compat.APIKeys { - key := strings.TrimSpace(compat.APIKeys[j]) - if key == "" { - continue + + // Handle new APIKeyEntries format (preferred) + if len(compat.APIKeyEntries) > 0 { + for j := range compat.APIKeyEntries { + entry := &compat.APIKeyEntries[j] + key := strings.TrimSpace(entry.APIKey) + if key == "" { + continue + } + proxyURL := strings.TrimSpace(entry.ProxyURL) + idKind := fmt.Sprintf("openai-compatibility:%s", providerName) + id, token := idGen.next(idKind, key, base, proxyURL) + attrs := map[string]string{ + "source": fmt.Sprintf("config:%s[%s]", providerName, token), + "base_url": base, + "api_key": key, + "compat_name": compat.Name, + "provider_key": providerName, + } + if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { + attrs["models_hash"] = hash + } + a := &coreauth.Auth{ + ID: id, + Provider: providerName, + Label: compat.Name, + Status: coreauth.StatusActive, + ProxyURL: proxyURL, + Attributes: attrs, + CreatedAt: now, + UpdatedAt: now, + } + out = append(out, a) } - idKind := fmt.Sprintf("openai-compatibility:%s", providerName) - id, token := idGen.next(idKind, key, base) - attrs := map[string]string{ - "source": fmt.Sprintf("config:%s[%s]", providerName, token), - "base_url": base, - "api_key": key, - "compat_name": compat.Name, - "provider_key": providerName, + } else { + // Handle legacy APIKeys format for backward compatibility + for j := range compat.APIKeys { + key := strings.TrimSpace(compat.APIKeys[j]) + if key == "" { + continue + } + idKind := fmt.Sprintf("openai-compatibility:%s", providerName) + id, token := idGen.next(idKind, key, base) + attrs := map[string]string{ + "source": fmt.Sprintf("config:%s[%s]", providerName, token), + "base_url": base, + "api_key": key, + "compat_name": compat.Name, + "provider_key": providerName, + } + if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { + attrs["models_hash"] = hash + } + a := &coreauth.Auth{ + ID: id, + Provider: providerName, + Label: compat.Name, + Status: coreauth.StatusActive, + Attributes: attrs, + CreatedAt: now, + UpdatedAt: now, + } + out = append(out, a) } - if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { - attrs["models_hash"] = hash - } - a := &coreauth.Auth{ - ID: id, - Provider: providerName, - Label: compat.Name, - Status: coreauth.StatusActive, - Attributes: attrs, - CreatedAt: now, - UpdatedAt: now, - } - out = append(out, a) } } } @@ -937,7 +978,12 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int) { if len(cfg.OpenAICompatibility) > 0 { // Do not construct legacy clients for OpenAI-compat providers; these are handled by the stateless executor. for _, compatConfig := range cfg.OpenAICompatibility { - openAICompatCount += len(compatConfig.APIKeys) + // Count from new APIKeyEntries format if present, otherwise fall back to legacy APIKeys + if len(compatConfig.APIKeyEntries) > 0 { + openAICompatCount += len(compatConfig.APIKeyEntries) + } else { + openAICompatCount += len(compatConfig.APIKeys) + } } } return glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount @@ -980,9 +1026,9 @@ func diffOpenAICompatibility(oldList, newList []config.OpenAICompatibility) []st } switch { case !oldOk: - changes = append(changes, fmt.Sprintf("provider added: %s (api-keys=%d, models=%d)", label, countNonEmptyStrings(newEntry.APIKeys), countOpenAIModels(newEntry.Models))) + changes = append(changes, fmt.Sprintf("provider added: %s (api-keys=%d, models=%d)", label, countAPIKeys(newEntry), countOpenAIModels(newEntry.Models))) case !newOk: - changes = append(changes, fmt.Sprintf("provider removed: %s (api-keys=%d, models=%d)", label, countNonEmptyStrings(oldEntry.APIKeys), countOpenAIModels(oldEntry.Models))) + changes = append(changes, fmt.Sprintf("provider removed: %s (api-keys=%d, models=%d)", label, countAPIKeys(oldEntry), countOpenAIModels(oldEntry.Models))) default: if detail := describeOpenAICompatibilityUpdate(oldEntry, newEntry); detail != "" { changes = append(changes, fmt.Sprintf("provider updated: %s %s", label, detail)) @@ -993,8 +1039,8 @@ func diffOpenAICompatibility(oldList, newList []config.OpenAICompatibility) []st } func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibility) string { - oldKeyCount := countNonEmptyStrings(oldEntry.APIKeys) - newKeyCount := countNonEmptyStrings(newEntry.APIKeys) + oldKeyCount := countAPIKeys(oldEntry) + newKeyCount := countAPIKeys(newEntry) oldModelCount := countOpenAIModels(oldEntry.Models) newModelCount := countOpenAIModels(newEntry.Models) details := make([]string, 0, 2) @@ -1010,6 +1056,21 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi return "(" + strings.Join(details, ", ") + ")" } +func countAPIKeys(entry config.OpenAICompatibility) int { + // Prefer new APIKeyEntries format + if len(entry.APIKeyEntries) > 0 { + count := 0 + for _, keyEntry := range entry.APIKeyEntries { + if strings.TrimSpace(keyEntry.APIKey) != "" { + count++ + } + } + return count + } + // Fall back to legacy APIKeys format + return countNonEmptyStrings(entry.APIKeys) +} + func countNonEmptyStrings(values []string) int { count := 0 for _, value := range values {