Support multiple Claude API keys with round-robin
Browse filesCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
internal/config/config.go
CHANGED
|
@@ -270,12 +270,26 @@ type CloakConfig struct {
|
|
| 270 |
SensitiveWords []string `yaml:"sensitive-words,omitempty" json:"sensitive-words,omitempty"`
|
| 271 |
}
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
// ClaudeKey represents the configuration for a Claude API key,
|
| 274 |
// including the API key itself and an optional base URL for the API endpoint.
|
| 275 |
type ClaudeKey struct {
|
| 276 |
// APIKey is the authentication key for accessing Claude API services.
|
|
|
|
| 277 |
APIKey string `yaml:"api-key" json:"api-key"`
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
// Priority controls selection preference when multiple credentials match.
|
| 280 |
// Higher values are preferred; defaults to 0.
|
| 281 |
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
|
|
@@ -288,6 +302,7 @@ type ClaudeKey struct {
|
|
| 288 |
BaseURL string `yaml:"base-url" json:"base-url"`
|
| 289 |
|
| 290 |
// ProxyURL overrides the global proxy setting for this API key if provided.
|
|
|
|
| 291 |
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
| 292 |
|
| 293 |
// Models defines upstream model names and aliases for request routing.
|
|
@@ -787,7 +802,7 @@ func (cfg *Config) SanitizeCodexKeys() {
|
|
| 787 |
cfg.CodexKey = out
|
| 788 |
}
|
| 789 |
|
| 790 |
-
// SanitizeClaudeKeys normalizes headers for Claude credentials.
|
| 791 |
func (cfg *Config) SanitizeClaudeKeys() {
|
| 792 |
if cfg == nil || len(cfg.ClaudeKey) == 0 {
|
| 793 |
return
|
|
@@ -797,6 +812,15 @@ func (cfg *Config) SanitizeClaudeKeys() {
|
|
| 797 |
entry.Prefix = normalizeModelPrefix(entry.Prefix)
|
| 798 |
entry.Headers = NormalizeHeaders(entry.Headers)
|
| 799 |
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
}
|
| 801 |
}
|
| 802 |
|
|
|
|
| 270 |
SensitiveWords []string `yaml:"sensitive-words,omitempty" json:"sensitive-words,omitempty"`
|
| 271 |
}
|
| 272 |
|
| 273 |
+
// ClaudeAPIKeyEntry represents an API key configuration with optional proxy setting for Claude.
|
| 274 |
+
type ClaudeAPIKeyEntry struct {
|
| 275 |
+
// APIKey is the authentication key for accessing Claude API services.
|
| 276 |
+
APIKey string `yaml:"api-key" json:"api-key"`
|
| 277 |
+
|
| 278 |
+
// ProxyURL overrides the global proxy setting for this API key if provided.
|
| 279 |
+
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
// ClaudeKey represents the configuration for a Claude API key,
|
| 283 |
// including the API key itself and an optional base URL for the API endpoint.
|
| 284 |
type ClaudeKey struct {
|
| 285 |
// APIKey is the authentication key for accessing Claude API services.
|
| 286 |
+
// Deprecated: Use APIKeyEntries for multiple API keys with round-robin support.
|
| 287 |
APIKey string `yaml:"api-key" json:"api-key"`
|
| 288 |
|
| 289 |
+
// APIKeyEntries defines API keys with optional per-key proxy configuration.
|
| 290 |
+
// When provided, each entry creates a separate Auth for round-robin load balancing.
|
| 291 |
+
APIKeyEntries []ClaudeAPIKeyEntry `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"`
|
| 292 |
+
|
| 293 |
// Priority controls selection preference when multiple credentials match.
|
| 294 |
// Higher values are preferred; defaults to 0.
|
| 295 |
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
|
|
|
|
| 302 |
BaseURL string `yaml:"base-url" json:"base-url"`
|
| 303 |
|
| 304 |
// ProxyURL overrides the global proxy setting for this API key if provided.
|
| 305 |
+
// Used as fallback when APIKeyEntries is empty or when entries don't specify ProxyURL.
|
| 306 |
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
| 307 |
|
| 308 |
// Models defines upstream model names and aliases for request routing.
|
|
|
|
| 802 |
cfg.CodexKey = out
|
| 803 |
}
|
| 804 |
|
| 805 |
+
// SanitizeClaudeKeys normalizes headers and API key entries for Claude credentials.
|
| 806 |
func (cfg *Config) SanitizeClaudeKeys() {
|
| 807 |
if cfg == nil || len(cfg.ClaudeKey) == 0 {
|
| 808 |
return
|
|
|
|
| 812 |
entry.Prefix = normalizeModelPrefix(entry.Prefix)
|
| 813 |
entry.Headers = NormalizeHeaders(entry.Headers)
|
| 814 |
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
|
| 815 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 816 |
+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
| 817 |
+
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
| 818 |
+
// Sanitize APIKeyEntries
|
| 819 |
+
for j := range entry.APIKeyEntries {
|
| 820 |
+
apiKeyEntry := &entry.APIKeyEntries[j]
|
| 821 |
+
apiKeyEntry.APIKey = strings.TrimSpace(apiKeyEntry.APIKey)
|
| 822 |
+
apiKeyEntry.ProxyURL = strings.TrimSpace(apiKeyEntry.ProxyURL)
|
| 823 |
+
}
|
| 824 |
}
|
| 825 |
}
|
| 826 |
|
internal/watcher/diff/config_diff.go
CHANGED
|
@@ -125,6 +125,11 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
|
| 125 |
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
|
| 126 |
changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i))
|
| 127 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
if !equalStringMap(o.Headers, n.Headers) {
|
| 129 |
changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i))
|
| 130 |
}
|
|
@@ -367,3 +372,19 @@ func equalUpstreamAPIKeys(a, b []config.AmpUpstreamAPIKeyEntry) bool {
|
|
| 367 |
}
|
| 368 |
return true
|
| 369 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
|
| 126 |
changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i))
|
| 127 |
}
|
| 128 |
+
if len(o.APIKeyEntries) != len(n.APIKeyEntries) {
|
| 129 |
+
changes = append(changes, fmt.Sprintf("claude[%d].api-key-entries count: %d -> %d", i, len(o.APIKeyEntries), len(n.APIKeyEntries)))
|
| 130 |
+
} else if !equalClaudeAPIKeyEntries(o.APIKeyEntries, n.APIKeyEntries) {
|
| 131 |
+
changes = append(changes, fmt.Sprintf("claude[%d].api-key-entries: updated", i))
|
| 132 |
+
}
|
| 133 |
if !equalStringMap(o.Headers, n.Headers) {
|
| 134 |
changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i))
|
| 135 |
}
|
|
|
|
| 372 |
}
|
| 373 |
return true
|
| 374 |
}
|
| 375 |
+
|
| 376 |
+
// equalClaudeAPIKeyEntries compares two slices of ClaudeAPIKeyEntry for equality.
|
| 377 |
+
func equalClaudeAPIKeyEntries(a, b []config.ClaudeAPIKeyEntry) bool {
|
| 378 |
+
if len(a) != len(b) {
|
| 379 |
+
return false
|
| 380 |
+
}
|
| 381 |
+
for i := range a {
|
| 382 |
+
if strings.TrimSpace(a[i].APIKey) != strings.TrimSpace(b[i].APIKey) {
|
| 383 |
+
return false
|
| 384 |
+
}
|
| 385 |
+
if strings.TrimSpace(a[i].ProxyURL) != strings.TrimSpace(b[i].ProxyURL) {
|
| 386 |
+
return false
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
return true
|
| 390 |
+
}
|
internal/watcher/synthesizer/config.go
CHANGED
|
@@ -77,7 +77,7 @@ func (s *ConfigSynthesizer) synthesizeKiroKeys(ctx *SynthesisContext) []*coreaut
|
|
| 77 |
}
|
| 78 |
|
| 79 |
attrs := map[string]string{
|
| 80 |
-
"source":
|
| 81 |
}
|
| 82 |
if entry.Priority != 0 {
|
| 83 |
attrs["priority"] = strconv.Itoa(entry.Priority)
|
|
@@ -156,44 +156,93 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
|
|
| 156 |
now := ctx.Now
|
| 157 |
idGen := ctx.IDGenerator
|
| 158 |
|
| 159 |
-
out := make([]*coreauth.Auth, 0
|
| 160 |
for i := range cfg.ClaudeKey {
|
| 161 |
ck := cfg.ClaudeKey[i]
|
| 162 |
-
key := strings.TrimSpace(ck.APIKey)
|
| 163 |
-
if key == "" {
|
| 164 |
-
continue
|
| 165 |
-
}
|
| 166 |
prefix := strings.TrimSpace(ck.Prefix)
|
| 167 |
base := strings.TrimSpace(ck.BaseURL)
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
}
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
-
ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
|
| 196 |
-
out = append(out, a)
|
| 197 |
}
|
| 198 |
return out
|
| 199 |
}
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
attrs := map[string]string{
|
| 80 |
+
"source": fmt.Sprintf("config:kiro[%s]", suffix),
|
| 81 |
}
|
| 82 |
if entry.Priority != 0 {
|
| 83 |
attrs["priority"] = strconv.Itoa(entry.Priority)
|
|
|
|
| 156 |
now := ctx.Now
|
| 157 |
idGen := ctx.IDGenerator
|
| 158 |
|
| 159 |
+
out := make([]*coreauth.Auth, 0)
|
| 160 |
for i := range cfg.ClaudeKey {
|
| 161 |
ck := cfg.ClaudeKey[i]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
prefix := strings.TrimSpace(ck.Prefix)
|
| 163 |
base := strings.TrimSpace(ck.BaseURL)
|
| 164 |
+
|
| 165 |
+
// Handle new APIKeyEntries format (preferred) for round-robin support
|
| 166 |
+
createdEntries := 0
|
| 167 |
+
for j := range ck.APIKeyEntries {
|
| 168 |
+
entry := &ck.APIKeyEntries[j]
|
| 169 |
+
key := strings.TrimSpace(entry.APIKey)
|
| 170 |
+
if key == "" {
|
| 171 |
+
continue
|
| 172 |
+
}
|
| 173 |
+
// Use entry proxy if set, otherwise fall back to key-level proxy
|
| 174 |
+
proxyURL := strings.TrimSpace(entry.ProxyURL)
|
| 175 |
+
if proxyURL == "" {
|
| 176 |
+
proxyURL = strings.TrimSpace(ck.ProxyURL)
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
id, token := idGen.Next("claude:apikey", key, base, proxyURL)
|
| 180 |
+
attrs := map[string]string{
|
| 181 |
+
"source": fmt.Sprintf("config:claude[%s]", token),
|
| 182 |
+
"api_key": key,
|
| 183 |
+
}
|
| 184 |
+
if ck.Priority != 0 {
|
| 185 |
+
attrs["priority"] = strconv.Itoa(ck.Priority)
|
| 186 |
+
}
|
| 187 |
+
if base != "" {
|
| 188 |
+
attrs["base_url"] = base
|
| 189 |
+
}
|
| 190 |
+
if hash := diff.ComputeClaudeModelsHash(ck.Models); hash != "" {
|
| 191 |
+
attrs["models_hash"] = hash
|
| 192 |
+
}
|
| 193 |
+
addConfigHeadersToAttrs(ck.Headers, attrs)
|
| 194 |
+
a := &coreauth.Auth{
|
| 195 |
+
ID: id,
|
| 196 |
+
Provider: "claude",
|
| 197 |
+
Label: "claude-apikey",
|
| 198 |
+
Prefix: prefix,
|
| 199 |
+
Status: coreauth.StatusActive,
|
| 200 |
+
ProxyURL: proxyURL,
|
| 201 |
+
Attributes: attrs,
|
| 202 |
+
CreatedAt: now,
|
| 203 |
+
UpdatedAt: now,
|
| 204 |
+
}
|
| 205 |
+
ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
|
| 206 |
+
out = append(out, a)
|
| 207 |
+
createdEntries++
|
| 208 |
}
|
| 209 |
+
|
| 210 |
+
// Fallback: use single APIKey if no APIKeyEntries (backward compatibility)
|
| 211 |
+
if createdEntries == 0 {
|
| 212 |
+
key := strings.TrimSpace(ck.APIKey)
|
| 213 |
+
if key == "" {
|
| 214 |
+
continue
|
| 215 |
+
}
|
| 216 |
+
proxyURL := strings.TrimSpace(ck.ProxyURL)
|
| 217 |
+
id, token := idGen.Next("claude:apikey", key, base, proxyURL)
|
| 218 |
+
attrs := map[string]string{
|
| 219 |
+
"source": fmt.Sprintf("config:claude[%s]", token),
|
| 220 |
+
"api_key": key,
|
| 221 |
+
}
|
| 222 |
+
if ck.Priority != 0 {
|
| 223 |
+
attrs["priority"] = strconv.Itoa(ck.Priority)
|
| 224 |
+
}
|
| 225 |
+
if base != "" {
|
| 226 |
+
attrs["base_url"] = base
|
| 227 |
+
}
|
| 228 |
+
if hash := diff.ComputeClaudeModelsHash(ck.Models); hash != "" {
|
| 229 |
+
attrs["models_hash"] = hash
|
| 230 |
+
}
|
| 231 |
+
addConfigHeadersToAttrs(ck.Headers, attrs)
|
| 232 |
+
a := &coreauth.Auth{
|
| 233 |
+
ID: id,
|
| 234 |
+
Provider: "claude",
|
| 235 |
+
Label: "claude-apikey",
|
| 236 |
+
Prefix: prefix,
|
| 237 |
+
Status: coreauth.StatusActive,
|
| 238 |
+
ProxyURL: proxyURL,
|
| 239 |
+
Attributes: attrs,
|
| 240 |
+
CreatedAt: now,
|
| 241 |
+
UpdatedAt: now,
|
| 242 |
+
}
|
| 243 |
+
ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
|
| 244 |
+
out = append(out, a)
|
| 245 |
}
|
|
|
|
|
|
|
| 246 |
}
|
| 247 |
return out
|
| 248 |
}
|