diff --git a/MANAGEMENT_API.md b/MANAGEMENT_API.md index a18fca99..2d1a6daa 100644 --- a/MANAGEMENT_API.md +++ b/MANAGEMENT_API.md @@ -116,6 +116,50 @@ Response (GET): Same request/response shapes as API keys. +### Codex API Keys (OpenAI) +- GET `/codex-api-key` + - Request: + ```bash + curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/codex-api-key + ``` + - Response: + ```json + { "codex-api-key": ["sk-proj-01","sk-proj-02"] } + ``` +- PUT `/codex-api-key` + - Request: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '["sk-proj-1","sk-proj-2"]' \ + http://localhost:8317/v0/management/codex-api-key + ``` + - Response: + ```json + { "status": "ok" } + ``` +- PATCH `/codex-api-key` + - Request: + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"old":"sk-proj-1","new":"sk-proj-1b"}' \ + http://localhost:8317/v0/management/codex-api-key + ``` + - Response: + ```json + { "status": "ok" } + ``` +- DELETE `/codex-api-key` + - Request: + ```bash + curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?value=sk-proj-2' + ``` + - Response: + ```json + { "status": "ok" } + ``` + ### Request Logging - GET `/request-log` — get boolean - PUT/PATCH `/request-log` — set boolean diff --git a/MANAGEMENT_API_CN.md b/MANAGEMENT_API_CN.md index 36839a04..7ebd0751 100644 --- a/MANAGEMENT_API_CN.md +++ b/MANAGEMENT_API_CN.md @@ -233,6 +233,50 @@ { "status": "ok" } ``` +### Codex API Key(OpenAI) +- GET `/codex-api-key` + - 请求: + ```bash + curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/codex-api-key + ``` + - 响应: + ```json + { "codex-api-key": ["sk-proj-01","sk-proj-02"] } + ``` +- PUT `/codex-api-key` + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '["sk-proj-1","sk-proj-2"]' \ + http://localhost:8317/v0/management/codex-api-key + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- PATCH `/codex-api-key` + - 请求: + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"old":"sk-proj-1","new":"sk-proj-1b"}' \ + http://localhost:8317/v0/management/codex-api-key + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- DELETE `/codex-api-key` + - 请求: + ```bash + curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?value=sk-proj-2' + ``` + - 响应: + ```json + { "status": "ok" } + ``` + ### 开启请求日志 - GET `/request-log` — 获取布尔值 - 请求: diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index d62771fa..d2d400c0 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -250,3 +250,77 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) { } c.JSON(400, gin.H{"error": "missing name or index"}) } + +// codex-api-key: []CodexKey +func (h *Handler) GetCodexKeys(c *gin.Context) { + c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey}) +} +func (h *Handler) PutCodexKeys(c *gin.Context) { + data, err := c.GetRawData() + if err != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + var arr []config.CodexKey + if err = json.Unmarshal(data, &arr); err != nil { + var obj struct { + Items []config.CodexKey `json:"items"` + } + if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + arr = obj.Items + } + h.cfg.CodexKey = arr + h.persist(c) +} +func (h *Handler) PatchCodexKey(c *gin.Context) { + var body struct { + Index *int `json:"index"` + Match *string `json:"match"` + Value *config.CodexKey `json:"value"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { + h.cfg.CodexKey[*body.Index] = *body.Value + h.persist(c) + return + } + if body.Match != nil { + for i := range h.cfg.CodexKey { + if h.cfg.CodexKey[i].APIKey == *body.Match { + h.cfg.CodexKey[i] = *body.Value + h.persist(c) + return + } + } + } + c.JSON(404, gin.H{"error": "item not found"}) +} +func (h *Handler) DeleteCodexKey(c *gin.Context) { + if val := c.Query("api-key"); val != "" { + out := make([]config.CodexKey, 0, len(h.cfg.CodexKey)) + for _, v := range h.cfg.CodexKey { + if v.APIKey != val { + out = append(out, v) + } + } + h.cfg.CodexKey = out + h.persist(c) + return + } + if idxStr := c.Query("index"); idxStr != "" { + var idx int + _, err := fmt.Sscanf(idxStr, "%d", &idx) + if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) { + h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...) + h.persist(c) + return + } + } + c.JSON(400, gin.H{"error": "missing api-key or index"}) +} diff --git a/internal/api/server.go b/internal/api/server.go index a1586077..3ba2d746 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -193,6 +193,11 @@ func (s *Server) setupRoutes() { mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey) mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey) + mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys) + mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys) + mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey) + mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey) + mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat) mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat) mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat) diff --git a/internal/client/codex_client.go b/internal/client/codex_client.go index 59d8a6b7..51acc345 100644 --- a/internal/client/codex_client.go +++ b/internal/client/codex_client.go @@ -19,6 +19,7 @@ import ( "github.com/google/uuid" "github.com/luispater/CLIProxyAPI/internal/auth" "github.com/luispater/CLIProxyAPI/internal/auth/codex" + "github.com/luispater/CLIProxyAPI/internal/auth/empty" "github.com/luispater/CLIProxyAPI/internal/config" . "github.com/luispater/CLIProxyAPI/internal/constant" "github.com/luispater/CLIProxyAPI/internal/interfaces" @@ -38,9 +39,11 @@ const ( type CodexClient struct { ClientBase codexAuth *codex.CodexAuth + // apiKeyIndex is the index of the API key to use from the config, -1 if not using API keys + apiKeyIndex int } -// NewCodexClient creates a new OpenAI client instance +// NewCodexClient creates a new OpenAI client instance using token-based authentication // // Parameters: // - cfg: The application configuration. @@ -63,7 +66,8 @@ func NewCodexClient(cfg *config.Config, ts *codex.CodexTokenStorage) (*CodexClie modelQuotaExceeded: make(map[string]*time.Time), tokenStorage: ts, }, - codexAuth: codex.NewCodexAuth(cfg), + codexAuth: codex.NewCodexAuth(cfg), + apiKeyIndex: -1, } // Initialize model registry and register OpenAI models @@ -73,6 +77,41 @@ func NewCodexClient(cfg *config.Config, ts *codex.CodexTokenStorage) (*CodexClie return client, nil } +// NewCodexClientWithKey creates a new Codex client instance using API key authentication. +// It initializes the client with the provided configuration and selects the API key +// at the specified index from the configuration. +// +// Parameters: +// - cfg: The application configuration. +// - apiKeyIndex: The index of the API key to use from the configuration. +// +// Returns: +// - *CodexClient: A new Codex client instance. +func NewCodexClientWithKey(cfg *config.Config, apiKeyIndex int) *CodexClient { + httpClient := util.SetProxy(cfg, &http.Client{}) + + // Generate unique client ID for API key client + clientID := fmt.Sprintf("codex-apikey-%d-%d", apiKeyIndex, time.Now().UnixNano()) + + client := &CodexClient{ + ClientBase: ClientBase{ + RequestMutex: &sync.Mutex{}, + httpClient: httpClient, + cfg: cfg, + modelQuotaExceeded: make(map[string]*time.Time), + tokenStorage: &empty.EmptyStorage{}, + }, + codexAuth: codex.NewCodexAuth(cfg), + apiKeyIndex: apiKeyIndex, + } + + // Initialize model registry and register OpenAI models + client.InitializeModelRegistry(clientID) + client.RegisterModels("codex", registry.GetOpenAIModels()) + + return client +} + // Type returns the client type func (c *CodexClient) Type() string { return CODEX @@ -102,6 +141,16 @@ func (c *CodexClient) CanProvideModel(modelName string) bool { return util.InArray(models, modelName) } +// GetAPIKey returns the API key for Codex API requests. +// If an API key index is specified, it returns the corresponding key from the configuration. +// Otherwise, it returns an empty string, indicating token-based authentication should be used. +func (c *CodexClient) GetAPIKey() string { + if c.apiKeyIndex != -1 { + return c.cfg.CodexKey[c.apiKeyIndex].APIKey + } + return "" +} + // GetUserAgent returns the user agent string for OpenAI API requests func (c *CodexClient) GetUserAgent() string { return "codex-cli" @@ -283,6 +332,11 @@ func (c *CodexClient) SaveTokenToFile() error { // Returns: // - error: An error if the refresh operation fails, nil otherwise. func (c *CodexClient) RefreshTokens(ctx context.Context) error { + // Check if we have a valid refresh token + if c.apiKeyIndex != -1 { + return fmt.Errorf("no refresh token available") + } + if c.tokenStorage == nil || c.tokenStorage.(*codex.CodexTokenStorage).RefreshToken == "" { return fmt.Errorf("no refresh token available") } @@ -364,6 +418,18 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string } url := fmt.Sprintf("%s%s", chatGPTEndpoint, endpoint) + accessToken := "" + + if c.apiKeyIndex != -1 { + // Using API key authentication - use configured base URL if provided + if c.cfg.CodexKey[c.apiKeyIndex].BaseURL != "" { + url = fmt.Sprintf("%s%s", c.cfg.CodexKey[c.apiKeyIndex].BaseURL, endpoint) + } + accessToken = c.cfg.CodexKey[c.apiKeyIndex].APIKey + } else { + // Using OAuth token authentication - use ChatGPT endpoint + accessToken = c.tokenStorage.(*codex.CodexTokenStorage).AccessToken + } // log.Debug(string(jsonBody)) // log.Debug(url) @@ -381,9 +447,16 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string req.Header.Set("Openai-Beta", "responses=experimental") req.Header.Set("Session_id", sessionID) req.Header.Set("Accept", "text/event-stream") - req.Header.Set("Chatgpt-Account-Id", c.tokenStorage.(*codex.CodexTokenStorage).AccountID) - req.Header.Set("Originator", "codex_cli_rs") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.tokenStorage.(*codex.CodexTokenStorage).AccessToken)) + + if c.apiKeyIndex != -1 { + // Using API key authentication + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + } else { + // Using OAuth token authentication - include ChatGPT specific headers + req.Header.Set("Chatgpt-Account-Id", c.tokenStorage.(*codex.CodexTokenStorage).AccountID) + req.Header.Set("Originator", "codex_cli_rs") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + } if c.cfg.RequestLog { if ginContext, ok := ctx.Value("gin").(*gin.Context); ok { @@ -391,7 +464,11 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string } } - log.Debugf("Use ChatGPT account %s for model %s", c.GetEmail(), modelName) + if c.apiKeyIndex != -1 { + log.Debugf("Use Codex API key %s for model %s", util.HideAPIKey(c.cfg.CodexKey[c.apiKeyIndex].APIKey), modelName) + } else { + log.Debugf("Use ChatGPT account %s for model %s", c.GetEmail(), modelName) + } resp, err := c.httpClient.Do(req) if err != nil { @@ -413,7 +490,11 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string } // GetEmail returns the email associated with the client's token storage. +// If the client is using API key authentication, it returns the API key. func (c *CodexClient) GetEmail() string { + if c.apiKeyIndex != -1 { + return c.cfg.CodexKey[c.apiKeyIndex].APIKey + } return c.tokenStorage.(*codex.CodexTokenStorage).Email } diff --git a/internal/cmd/run.go b/internal/cmd/run.go index c86ca3eb..37b86841 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -150,6 +150,15 @@ func StartService(cfg *config.Config, configPath string) { } } + if len(cfg.CodexKey) > 0 { + // Initialize clients with Codex API Keys if provided in configuration. + for i := 0; i < len(cfg.CodexKey); i++ { + log.Debug("Initializing with Codex API Key...") + cliClient := client.NewCodexClientWithKey(cfg, i) + cliClients = append(cliClients, cliClient) + } + } + if len(cfg.OpenAICompatibility) > 0 { // Initialize clients for OpenAI compatibility configurations for _, compatConfig := range cfg.OpenAICompatibility { @@ -223,12 +232,13 @@ func StartService(cfg *config.Config, configPath string) { checkAndRefresh := func() { for i := 0; i < len(cliClients); i++ { if codexCli, ok := cliClients[i].(*client.CodexClient); ok { - ts := codexCli.TokenStorage().(*codex.CodexTokenStorage) - if ts != nil && ts.Expire != "" { - if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil { - if time.Until(expTime) <= 5*24*time.Hour { - log.Debugf("refreshing codex tokens for %s", codexCli.GetEmail()) - _ = codexCli.RefreshTokens(ctxRefresh) + if ts, isCodexTS := codexCli.TokenStorage().(*claude.ClaudeTokenStorage); isCodexTS { + if ts != nil && ts.Expire != "" { + if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil { + if time.Until(expTime) <= 5*24*time.Hour { + log.Debugf("refreshing codex tokens for %s", codexCli.GetEmail()) + _ = codexCli.RefreshTokens(ctxRefresh) + } } } } diff --git a/internal/config/config.go b/internal/config/config.go index f6d2859e..b25fa433 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,9 @@ type Config struct { // ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file. ClaudeKey []ClaudeKey `yaml:"claude-api-key"` + // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. + CodexKey []CodexKey `yaml:"codex-api-key"` + // OpenAICompatibility defines OpenAI API compatibility configurations for external providers. OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility"` @@ -83,6 +86,17 @@ type ClaudeKey struct { BaseURL string `yaml:"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"` + + // 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"` +} + // OpenAICompatibility represents the configuration for OpenAI API compatibility // with external providers, allowing model aliases to be routed through OpenAI API format. type OpenAICompatibility struct { @@ -286,58 +300,6 @@ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node { return val } -// Helpers to update sequences in place to preserve existing comments/anchors -func setStringListInPlace(mapNode *yaml.Node, key string, arr []string) { - if len(arr) == 0 { - setNullValue(mapNode, key) - return - } - v := getOrCreateMapValue(mapNode, key) - if v.Kind != yaml.SequenceNode { - v.Kind = yaml.SequenceNode - v.Tag = "!!seq" - v.Content = nil - } - // Update in place - oldLen := len(v.Content) - minLen := oldLen - if len(arr) < minLen { - minLen = len(arr) - } - for i := 0; i < minLen; i++ { - if v.Content[i] == nil { - v.Content[i] = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str"} - } - v.Content[i].Kind = yaml.ScalarNode - v.Content[i].Tag = "!!str" - v.Content[i].Value = arr[i] - } - if len(arr) > oldLen { - for i := oldLen; i < len(arr); i++ { - v.Content = append(v.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: arr[i]}) - } - } else if len(arr) < oldLen { - v.Content = v.Content[:len(arr)] - } -} - -func setMappingScalar(mapNode *yaml.Node, key string, val string) { - v := getOrCreateMapValue(mapNode, key) - v.Kind = yaml.ScalarNode - v.Tag = "!!str" - v.Value = val -} - -// setNullValue ensures a mapping key exists and is set to an explicit null scalar, -// so that it renders as `key:` without `[]`. -func setNullValue(mapNode *yaml.Node, key string) { - // Represent as YAML null scalar without explicit value so it renders as `key:` - v := getOrCreateMapValue(mapNode, key) - v.Kind = yaml.ScalarNode - v.Tag = "!!null" - v.Value = "" -} - // 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. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index f8bf488a..15f295ab 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -185,6 +185,9 @@ func (w *Watcher) reloadConfig() { if len(oldConfig.ClaudeKey) != len(newConfig.ClaudeKey) { log.Debugf(" claude-api-key count: %d -> %d", len(oldConfig.ClaudeKey), len(newConfig.ClaudeKey)) } + if len(oldConfig.CodexKey) != len(newConfig.CodexKey) { + log.Debugf(" codex-api-key count: %d -> %d", len(oldConfig.CodexKey), len(newConfig.CodexKey)) + } if oldConfig.AllowLocalhostUnauthenticated != newConfig.AllowLocalhostUnauthenticated { log.Debugf(" allow-localhost-unauthenticated: %t -> %t", oldConfig.AllowLocalhostUnauthenticated, newConfig.AllowLocalhostUnauthenticated) } @@ -364,6 +367,18 @@ func (w *Watcher) reloadClients() { log.Debugf("Successfully initialized %d Claude API Key clients", claudeAPIKeyCount) } + codexAPIKeyCount := 0 + if len(cfg.CodexKey) > 0 { + log.Debugf("processing %d Codex API Keys", len(cfg.CodexKey)) + for i := 0; i < len(cfg.CodexKey); i++ { + log.Debugf("Initializing with Codex API Key %d...", i+1) + cliClient := client.NewCodexClientWithKey(cfg, i) + newClients = append(newClients, cliClient) + codexAPIKeyCount++ + } + log.Debugf("Successfully initialized %d Codex API Key clients", codexAPIKeyCount) + } + // Add clients for OpenAI compatibility providers if configured openAICompatCount := 0 if len(cfg.OpenAICompatibility) > 0 {