Add support for Codex API key authentication

- Introduced functionality to handle Codex API keys, including initialization and management via new endpoints in the management API.
- Updated Codex client to support both OAuth and API key authentication.
- Documented Codex API key configuration in both English and Chinese README files.
- Enhanced logging to distinguish between API key and OAuth usage scenarios.
This commit is contained in:
Luis Pater
2025-09-03 03:36:56 +08:00
parent b6f8677b01
commit c4a42eb1f0
8 changed files with 299 additions and 64 deletions

View File

@@ -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"})
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}
}
}
}

View File

@@ -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.

View File

@@ -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 {