-
-
Notifications
You must be signed in to change notification settings - Fork 295
feat(copilot): add shared infrastructure and config [1/5] #380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -12,6 +12,7 @@ import ( | |||||
| "strings" | ||||||
| "syscall" | ||||||
|
|
||||||
| copilotshared "github.com/router-for-me/CLIProxyAPI/v6/internal/copilot" | ||||||
| "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" | ||||||
| "golang.org/x/crypto/bcrypt" | ||||||
| "gopkg.in/yaml.v3" | ||||||
|
|
@@ -66,6 +67,9 @@ type Config struct { | |||||
|
|
||||||
| // RequestRetry defines the retry times when the request failed. | ||||||
| RequestRetry int `yaml:"request-retry" json:"request-retry"` | ||||||
| // ScannerBufferSize defines the buffer size for reading response streams (in bytes). | ||||||
| // If 0, a default of 20MB is used. | ||||||
| ScannerBufferSize int `yaml:"scanner-buffer-size" json:"scanner-buffer-size"` | ||||||
| // MaxRetryInterval defines the maximum wait time in seconds before retrying a cooled-down credential. | ||||||
| MaxRetryInterval int `yaml:"max-retry-interval" json:"max-retry-interval"` | ||||||
|
|
||||||
|
|
@@ -75,6 +79,9 @@ type Config struct { | |||||
| // 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"` | ||||||
|
|
||||||
| // CopilotKey defines GitHub Copilot API configurations. | ||||||
| CopilotKey []CopilotKey `yaml:"copilot-api-key" json:"copilot-api-key"` | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To align with the suggested change in
Suggested change
|
||||||
|
|
||||||
| // OpenAICompatibility defines OpenAI API compatibility configurations for external providers. | ||||||
| OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"` | ||||||
|
|
||||||
|
|
@@ -194,6 +201,21 @@ type CodexKey struct { | |||||
| ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` | ||||||
| } | ||||||
|
|
||||||
| // CopilotKey represents the configuration for GitHub Copilot API access. | ||||||
| // Authentication is handled via device code OAuth flow, not API keys. | ||||||
| type CopilotKey struct { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The struct name
Suggested change
|
||||||
| // AccountType is the Copilot subscription type (individual, business, enterprise). | ||||||
| // Defaults to "individual" if not specified. | ||||||
| AccountType string `yaml:"account-type" json:"account-type"` | ||||||
|
|
||||||
| // ProxyURL overrides the global proxy setting for Copilot requests if provided. | ||||||
| ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"` | ||||||
|
|
||||||
| // AgentInitiatorPersist, when true, forces subsequent Copilot requests sharing the | ||||||
| // same prompt_cache_key to send X-Initiator=agent after the first call. Default false. | ||||||
| AgentInitiatorPersist bool `yaml:"agent-initiator-persist" json:"agent-initiator-persist"` | ||||||
| } | ||||||
|
|
||||||
| // GeminiKey represents the configuration for a Gemini API key, | ||||||
| // including optional overrides for upstream base URL, proxy routing, and headers. | ||||||
| type GeminiKey struct { | ||||||
|
|
@@ -328,6 +350,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { | |||||
| // Sanitize Codex keys: drop entries without base-url | ||||||
| cfg.SanitizeCodexKeys() | ||||||
|
|
||||||
| // Sanitize Copilot keys: normalize account type | ||||||
| cfg.SanitizeCopilotKeys() | ||||||
|
|
||||||
| // Sanitize Claude key headers | ||||||
| cfg.SanitizeClaudeKeys() | ||||||
|
|
||||||
|
|
@@ -383,6 +408,25 @@ func (cfg *Config) SanitizeCodexKeys() { | |||||
| cfg.CodexKey = out | ||||||
| } | ||||||
|
|
||||||
| // SanitizeCopilotKeys normalizes Copilot configurations. | ||||||
| // It sets default account type and trims whitespace. | ||||||
| func (cfg *Config) SanitizeCopilotKeys() { | ||||||
| if cfg == nil || len(cfg.CopilotKey) == 0 { | ||||||
| return | ||||||
| } | ||||||
| for i := range cfg.CopilotKey { | ||||||
| entry := &cfg.CopilotKey[i] | ||||||
| entry.AccountType = strings.TrimSpace(strings.ToLower(entry.AccountType)) | ||||||
| entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) | ||||||
| validation := copilotshared.ValidateAccountType(entry.AccountType) | ||||||
| if validation.Valid { | ||||||
| entry.AccountType = string(validation.AccountType) | ||||||
| } else { | ||||||
| entry.AccountType = string(copilotshared.DefaultAccountType) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // SanitizeClaudeKeys normalizes headers for Claude credentials. | ||||||
| func (cfg *Config) SanitizeClaudeKeys() { | ||||||
| if cfg == nil || len(cfg.ClaudeKey) == 0 { | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package copilot | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
| ) | ||
|
|
||
| // AccountType is the Copilot subscription type. | ||
| type AccountType string | ||
|
|
||
| const ( | ||
| AccountTypeIndividual AccountType = "individual" | ||
| AccountTypeBusiness AccountType = "business" | ||
| AccountTypeEnterprise AccountType = "enterprise" | ||
| ) | ||
|
|
||
| // ValidAccountTypes is the canonical list of valid Copilot account types. | ||
| var ValidAccountTypes = []string{string(AccountTypeIndividual), string(AccountTypeBusiness), string(AccountTypeEnterprise)} | ||
|
|
||
| const DefaultAccountType = AccountTypeIndividual | ||
|
|
||
| // AccountTypeValidationResult contains the result of account type validation. | ||
| type AccountTypeValidationResult struct { | ||
| AccountType AccountType | ||
| Valid bool | ||
| ValidValues []string | ||
| DefaultValue string | ||
| ErrorMessage string | ||
| } | ||
|
|
||
| // ParseAccountType parses a string into an AccountType. | ||
| // Returns the parsed type and whether the input was a valid account type. | ||
| // Empty or invalid strings return (AccountTypeIndividual, false). | ||
| func ParseAccountType(s string) (AccountType, bool) { | ||
| switch strings.ToLower(strings.TrimSpace(s)) { | ||
| case "individual": | ||
| return AccountTypeIndividual, true | ||
| case "business": | ||
| return AccountTypeBusiness, true | ||
| case "enterprise": | ||
| return AccountTypeEnterprise, true | ||
| default: | ||
| return AccountTypeIndividual, false | ||
| } | ||
| } | ||
|
|
||
| // ValidateAccountType validates an account type string and returns details suitable for API responses. | ||
| func ValidateAccountType(s string) AccountTypeValidationResult { | ||
| accountType, valid := ParseAccountType(s) | ||
| result := AccountTypeValidationResult{ | ||
| AccountType: accountType, | ||
| Valid: valid, | ||
| ValidValues: ValidAccountTypes, | ||
| DefaultValue: string(DefaultAccountType), | ||
| } | ||
| if !valid && s != "" { | ||
| result.ErrorMessage = fmt.Sprintf("invalid account_type '%s', valid values are: %s", s, strings.Join(ValidAccountTypes, ", ")) | ||
| } | ||
| return result | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| package copilot | ||
|
|
||
| import ( | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestParseAccountType(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| input string | ||
| wantType AccountType | ||
| wantValid bool | ||
| }{ | ||
| { | ||
| name: "individual lowercase", | ||
| input: "individual", | ||
| wantType: AccountTypeIndividual, | ||
| wantValid: true, | ||
| }, | ||
| { | ||
| name: "individual uppercase", | ||
| input: "INDIVIDUAL", | ||
| wantType: AccountTypeIndividual, | ||
| wantValid: true, | ||
| }, | ||
| { | ||
| name: "individual mixed case", | ||
| input: "Individual", | ||
| wantType: AccountTypeIndividual, | ||
| wantValid: true, | ||
| }, | ||
| { | ||
| name: "business", | ||
| input: "business", | ||
| wantType: AccountTypeBusiness, | ||
| wantValid: true, | ||
| }, | ||
| { | ||
| name: "enterprise", | ||
| input: "enterprise", | ||
| wantType: AccountTypeEnterprise, | ||
| wantValid: true, | ||
| }, | ||
| { | ||
| name: "empty string", | ||
| input: "", | ||
| wantType: AccountTypeIndividual, | ||
| wantValid: false, | ||
| }, | ||
| { | ||
| name: "invalid value", | ||
| input: "invalid", | ||
| wantType: AccountTypeIndividual, | ||
| wantValid: false, | ||
| }, | ||
| { | ||
| name: "whitespace", | ||
| input: " individual ", | ||
| wantType: AccountTypeIndividual, | ||
| wantValid: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| gotType, gotValid := ParseAccountType(tt.input) | ||
| if gotType != tt.wantType { | ||
| t.Errorf("ParseAccountType(%q) type = %v, want %v", tt.input, gotType, tt.wantType) | ||
| } | ||
| if gotValid != tt.wantValid { | ||
| t.Errorf("ParseAccountType(%q) valid = %v, want %v", tt.input, gotValid, tt.wantValid) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestValidateAccountType(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| input string | ||
| wantValid bool | ||
| wantHasError bool | ||
| wantType AccountType | ||
| }{ | ||
| { | ||
| name: "valid individual", | ||
| input: "individual", | ||
| wantValid: true, | ||
| wantHasError: false, | ||
| wantType: AccountTypeIndividual, | ||
| }, | ||
| { | ||
| name: "valid business", | ||
| input: "business", | ||
| wantValid: true, | ||
| wantHasError: false, | ||
| wantType: AccountTypeBusiness, | ||
| }, | ||
| { | ||
| name: "valid enterprise", | ||
| input: "enterprise", | ||
| wantValid: true, | ||
| wantHasError: false, | ||
| wantType: AccountTypeEnterprise, | ||
| }, | ||
| { | ||
| name: "invalid value", | ||
| input: "invalid", | ||
| wantValid: false, | ||
| wantHasError: true, | ||
| wantType: AccountTypeIndividual, | ||
| }, | ||
| { | ||
| name: "empty string", | ||
| input: "", | ||
| wantValid: false, | ||
| wantHasError: false, // empty string doesn't generate error message | ||
| wantType: AccountTypeIndividual, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| result := ValidateAccountType(tt.input) | ||
| if result.Valid != tt.wantValid { | ||
| t.Errorf("ValidateAccountType(%q).Valid = %v, want %v", tt.input, result.Valid, tt.wantValid) | ||
| } | ||
| if (result.ErrorMessage != "") != tt.wantHasError { | ||
| t.Errorf("ValidateAccountType(%q).ErrorMessage = %q, wantHasError %v", tt.input, result.ErrorMessage, tt.wantHasError) | ||
| } | ||
| if result.AccountType != tt.wantType { | ||
| t.Errorf("ValidateAccountType(%q).AccountType = %v, want %v", tt.input, result.AccountType, tt.wantType) | ||
| } | ||
| if result.DefaultValue != string(DefaultAccountType) { | ||
| t.Errorf("ValidateAccountType(%q).DefaultValue = %q, want %q", tt.input, result.DefaultValue, DefaultAccountType) | ||
| } | ||
| if len(result.ValidValues) != 3 { | ||
| t.Errorf("ValidateAccountType(%q).ValidValues has %d items, want 3", tt.input, len(result.ValidValues)) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestAccountTypeConstants(t *testing.T) { | ||
| if AccountTypeIndividual != "individual" { | ||
| t.Errorf("AccountTypeIndividual = %q, want %q", AccountTypeIndividual, "individual") | ||
| } | ||
| if AccountTypeBusiness != "business" { | ||
| t.Errorf("AccountTypeBusiness = %q, want %q", AccountTypeBusiness, "business") | ||
| } | ||
| if AccountTypeEnterprise != "enterprise" { | ||
| t.Errorf("AccountTypeEnterprise = %q, want %q", AccountTypeEnterprise, "enterprise") | ||
| } | ||
| if DefaultAccountType != AccountTypeIndividual { | ||
| t.Errorf("DefaultAccountType = %q, want %q", DefaultAccountType, AccountTypeIndividual) | ||
| } | ||
| } | ||
|
|
||
| func TestValidAccountTypes(t *testing.T) { | ||
| expected := []string{"individual", "business", "enterprise"} | ||
| if len(ValidAccountTypes) != len(expected) { | ||
| t.Fatalf("ValidAccountTypes has %d items, want %d", len(ValidAccountTypes), len(expected)) | ||
| } | ||
| for i, v := range expected { | ||
| if ValidAccountTypes[i] != v { | ||
| t.Errorf("ValidAccountTypes[%d] = %q, want %q", i, ValidAccountTypes[i], v) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The configuration key
copilot-api-keyis misleading since this configuration does not involve an API key, as noted in the comments. To improve clarity, consider renaming it tocopilot.#copilot: