| @@ -0,0 +1,58 @@ | |||||
| package common | |||||
| import ( | |||||
| "errors" | |||||
| "strings" | |||||
| ) | |||||
| type CodexCredentialMode string | |||||
| const ( | |||||
| CodexCredentialModeAPIKey CodexCredentialMode = "api_key" | |||||
| CodexCredentialModeOAuth CodexCredentialMode = "oauth" | |||||
| ) | |||||
| var ( | |||||
| ErrCodexOAuthCredentialRequired = errors.New("codex channel: oauth credential required") | |||||
| ErrCodexOAuthCredentialInvalidJSON = errors.New("codex channel: invalid oauth key json") | |||||
| ErrCodexOAuthAccessTokenRequired = errors.New("codex channel: access_token is required") | |||||
| ErrCodexOAuthAccountIDRequired = errors.New("codex channel: account_id is required") | |||||
| ) | |||||
| type CodexOAuthCredential struct { | |||||
| IDToken string `json:"id_token,omitempty"` | |||||
| AccessToken string `json:"access_token,omitempty"` | |||||
| RefreshToken string `json:"refresh_token,omitempty"` | |||||
| AccountID string `json:"account_id,omitempty"` | |||||
| LastRefresh string `json:"last_refresh,omitempty"` | |||||
| Email string `json:"email,omitempty"` | |||||
| Type string `json:"type,omitempty"` | |||||
| Expired string `json:"expired,omitempty"` | |||||
| } | |||||
| func ParseCodexOAuthCredential(raw string) (*CodexOAuthCredential, error) { | |||||
| trimmed := strings.TrimSpace(raw) | |||||
| if trimmed == "" || !strings.HasPrefix(trimmed, "{") { | |||||
| return nil, ErrCodexOAuthCredentialRequired | |||||
| } | |||||
| var credential CodexOAuthCredential | |||||
| if err := Unmarshal([]byte(trimmed), &credential); err != nil { | |||||
| return nil, ErrCodexOAuthCredentialInvalidJSON | |||||
| } | |||||
| if strings.TrimSpace(credential.AccessToken) == "" { | |||||
| return nil, ErrCodexOAuthAccessTokenRequired | |||||
| } | |||||
| if strings.TrimSpace(credential.AccountID) == "" { | |||||
| return nil, ErrCodexOAuthAccountIDRequired | |||||
| } | |||||
| return &credential, nil | |||||
| } | |||||
| func DetectCodexCredentialMode(raw string) CodexCredentialMode { | |||||
| if _, err := ParseCodexOAuthCredential(raw); err == nil { | |||||
| return CodexCredentialModeOAuth | |||||
| } | |||||
| return CodexCredentialModeAPIKey | |||||
| } | |||||
| @@ -3,6 +3,7 @@ package controller | |||||
| import ( | import ( | ||||
| "context" | "context" | ||||
| "encoding/json" | "encoding/json" | ||||
| "errors" | |||||
| "fmt" | "fmt" | ||||
| "net/http" | "net/http" | ||||
| "strconv" | "strconv" | ||||
| @@ -615,20 +616,22 @@ func validateChannel(channel *model.Channel, isAdd bool) error { | |||||
| // Codex OAuth key validation (optional, only when JSON object is provided) | // Codex OAuth key validation (optional, only when JSON object is provided) | ||||
| if channel.Type == constant.ChannelTypeCodex { | if channel.Type == constant.ChannelTypeCodex { | ||||
| trimmedKey := strings.TrimSpace(channel.Key) | trimmedKey := strings.TrimSpace(channel.Key) | ||||
| if isAdd || trimmedKey != "" { | |||||
| if !strings.HasPrefix(trimmedKey, "{") { | |||||
| return fmt.Errorf("Codex key must be a valid JSON object") | |||||
| } | |||||
| var keyMap map[string]any | |||||
| if err := common.Unmarshal([]byte(trimmedKey), &keyMap); err != nil { | |||||
| if isAdd && trimmedKey == "" { | |||||
| return fmt.Errorf("Codex key cannot be empty") | |||||
| } | |||||
| if strings.HasPrefix(trimmedKey, "{") { | |||||
| if _, err := common.ParseCodexOAuthCredential(trimmedKey); err != nil { | |||||
| if errors.Is(err, common.ErrCodexOAuthCredentialInvalidJSON) { | |||||
| return fmt.Errorf("Codex key must be a valid JSON object") | |||||
| } | |||||
| if errors.Is(err, common.ErrCodexOAuthAccessTokenRequired) { | |||||
| return fmt.Errorf("Codex key JSON must include access_token") | |||||
| } | |||||
| if errors.Is(err, common.ErrCodexOAuthAccountIDRequired) { | |||||
| return fmt.Errorf("Codex key JSON must include account_id") | |||||
| } | |||||
| return fmt.Errorf("Codex key must be a valid JSON object") | return fmt.Errorf("Codex key must be a valid JSON object") | ||||
| } | } | ||||
| if v, ok := keyMap["access_token"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" { | |||||
| return fmt.Errorf("Codex key JSON must include access_token") | |||||
| } | |||||
| if v, ok := keyMap["account_id"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" { | |||||
| return fmt.Errorf("Codex key JSON must include account_id") | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -647,6 +650,10 @@ func RefreshCodexChannelCredential(c *gin.Context) { | |||||
| oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true}) | oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true}) | ||||
| if err != nil { | if err != nil { | ||||
| if errors.Is(err, common.ErrCodexOAuthCredentialRequired) { | |||||
| c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前凭证方式不支持刷新凭证"}) | |||||
| return | |||||
| } | |||||
| common.SysError("failed to refresh codex channel credential: " + err.Error()) | common.SysError("failed to refresh codex channel credential: " + err.Error()) | ||||
| c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"}) | c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"}) | ||||
| return | return | ||||
| @@ -2112,11 +2119,11 @@ func GetUserChannelsForBinding(c *gin.Context) { | |||||
| result := make([]gin.H, 0, len(channels)) | result := make([]gin.H, 0, len(channels)) | ||||
| for _, ch := range channels { | for _, ch := range channels { | ||||
| result = append(result, gin.H{ | result = append(result, gin.H{ | ||||
| "id": ch.Id, | |||||
| "name": ch.Name, | |||||
| "id": ch.Id, | |||||
| "name": ch.Name, | |||||
| "public_name": ch.PublicName, | "public_name": ch.PublicName, | ||||
| "type": ch.Type, | |||||
| "remark": ch.Remark, | |||||
| "type": ch.Type, | |||||
| "remark": ch.Remark, | |||||
| }) | }) | ||||
| } | } | ||||
| @@ -0,0 +1,111 @@ | |||||
| package controller | |||||
| import ( | |||||
| "bytes" | |||||
| "net/http" | |||||
| "net/http/httptest" | |||||
| "testing" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/constant" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "github.com/gin-gonic/gin" | |||||
| "github.com/glebarez/sqlite" | |||||
| "github.com/stretchr/testify/assert" | |||||
| "github.com/stretchr/testify/require" | |||||
| "gorm.io/gorm" | |||||
| ) | |||||
| func setupCodexChannelDB(t *testing.T, key string) *gorm.DB { | |||||
| t.Helper() | |||||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||||
| require.NoError(t, err) | |||||
| sqlDB, _ := db.DB() | |||||
| sqlDB.SetMaxOpenConns(1) | |||||
| origDB := model.DB | |||||
| origLogDB := model.LOG_DB | |||||
| model.DB = db | |||||
| model.LOG_DB = db | |||||
| common.UsingSQLite = true | |||||
| common.RedisEnabled = false | |||||
| require.NoError(t, db.AutoMigrate(&model.Channel{})) | |||||
| require.NoError(t, db.Create(&model.Channel{ | |||||
| Id: 1, | |||||
| Name: "codex-channel", | |||||
| PublicName: "codex-channel", | |||||
| Type: constant.ChannelTypeCodex, | |||||
| Key: key, | |||||
| Status: common.ChannelStatusEnabled, | |||||
| }).Error) | |||||
| t.Cleanup(func() { | |||||
| model.DB = origDB | |||||
| model.LOG_DB = origLogDB | |||||
| _ = sqlDB.Close() | |||||
| }) | |||||
| return db | |||||
| } | |||||
| func setupCodexChannelRouter(t *testing.T, key string) *gin.Engine { | |||||
| t.Helper() | |||||
| setupCodexChannelDB(t, key) | |||||
| gin.SetMode(gin.TestMode) | |||||
| r := gin.New() | |||||
| g := r.Group("/api/channel") | |||||
| g.POST("/:id/codex/refresh", RefreshCodexChannelCredential) | |||||
| g.GET("/:id/codex/usage", GetCodexChannelUsage) | |||||
| return r | |||||
| } | |||||
| func TestValidateChannelAcceptsCodexAPIKey(t *testing.T) { | |||||
| channel := &model.Channel{ | |||||
| Name: "codex-api-key", | |||||
| PublicName: "codex-api-key", | |||||
| Type: constant.ChannelTypeCodex, | |||||
| Key: "sk-codex-api-key", | |||||
| } | |||||
| require.NoError(t, validateChannel(channel, true)) | |||||
| } | |||||
| func TestValidateChannelRejectsCodexOAuthWithoutAccountID(t *testing.T) { | |||||
| channel := &model.Channel{ | |||||
| Name: "codex-oauth", | |||||
| PublicName: "codex-oauth", | |||||
| Type: constant.ChannelTypeCodex, | |||||
| Key: `{"access_token":"token-only"}`, | |||||
| } | |||||
| err := validateChannel(channel, true) | |||||
| require.Error(t, err) | |||||
| assert.Contains(t, err.Error(), "account_id") | |||||
| } | |||||
| func TestRefreshCodexChannelCredentialRejectsAPIKeyMode(t *testing.T) { | |||||
| router := setupCodexChannelRouter(t, "sk-codex-api-key") | |||||
| req := httptest.NewRequest(http.MethodPost, "/api/channel/1/codex/refresh", bytes.NewReader([]byte(`{}`))) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| assert.Equal(t, http.StatusOK, w.Code) | |||||
| assert.Contains(t, w.Body.String(), "当前凭证方式不支持刷新凭证") | |||||
| } | |||||
| func TestGetCodexChannelUsageRejectsAPIKeyMode(t *testing.T) { | |||||
| router := setupCodexChannelRouter(t, "sk-codex-api-key") | |||||
| req := httptest.NewRequest(http.MethodGet, "/api/channel/1/codex/usage", nil) | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| assert.Equal(t, http.StatusOK, w.Code) | |||||
| assert.Contains(t, w.Body.String(), "当前凭证方式不支持查看用量") | |||||
| } | |||||
| @@ -2,6 +2,7 @@ package controller | |||||
| import ( | import ( | ||||
| "context" | "context" | ||||
| "errors" | |||||
| "fmt" | "fmt" | ||||
| "net/http" | "net/http" | ||||
| "strconv" | "strconv" | ||||
| @@ -11,7 +12,6 @@ import ( | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/constant" | "github.com/QuantumNous/new-api/constant" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| "github.com/QuantumNous/new-api/relay/channel/codex" | |||||
| "github.com/QuantumNous/new-api/service" | "github.com/QuantumNous/new-api/service" | ||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| @@ -42,22 +42,19 @@ func GetCodexChannelUsage(c *gin.Context) { | |||||
| return | return | ||||
| } | } | ||||
| oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key)) | |||||
| oauthKey, err := common.ParseCodexOAuthCredential(strings.TrimSpace(ch.Key)) | |||||
| if err != nil { | if err != nil { | ||||
| if errors.Is(err, common.ErrCodexOAuthCredentialRequired) { | |||||
| c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前凭证方式不支持查看用量"}) | |||||
| return | |||||
| } | |||||
| common.SysError("failed to parse oauth key: " + err.Error()) | common.SysError("failed to parse oauth key: " + err.Error()) | ||||
| c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"}) | c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"}) | ||||
| return | return | ||||
| } | } | ||||
| accessToken := strings.TrimSpace(oauthKey.AccessToken) | accessToken := strings.TrimSpace(oauthKey.AccessToken) | ||||
| accountID := strings.TrimSpace(oauthKey.AccountID) | accountID := strings.TrimSpace(oauthKey.AccountID) | ||||
| if accessToken == "" { | |||||
| c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: access_token is required"}) | |||||
| return | |||||
| } | |||||
| if accountID == "" { | |||||
| c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: account_id is required"}) | |||||
| return | |||||
| } | |||||
| client, err := service.NewProxyHttpClient(ch.GetSetting().Proxy) | client, err := service.NewProxyHttpClient(ch.GetSetting().Proxy) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -98,6 +95,7 @@ func GetCodexChannelUsage(c *gin.Context) { | |||||
| ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second) | ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second) | ||||
| defer cancel2() | defer cancel2() | ||||
| statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID) | statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID) | ||||
| if err != nil { | if err != nil { | ||||
| common.SysError("failed to fetch codex usage after refresh: " + err.Error()) | common.SysError("failed to fetch codex usage after refresh: " + err.Error()) | ||||
| @@ -2,7 +2,6 @@ package service | |||||
| import ( | import ( | ||||
| "context" | "context" | ||||
| "errors" | |||||
| "fmt" | "fmt" | ||||
| "strings" | "strings" | ||||
| "time" | "time" | ||||
| @@ -16,28 +15,7 @@ type CodexCredentialRefreshOptions struct { | |||||
| ResetCaches bool | ResetCaches bool | ||||
| } | } | ||||
| type CodexOAuthKey struct { | |||||
| IDToken string `json:"id_token,omitempty"` | |||||
| AccessToken string `json:"access_token,omitempty"` | |||||
| RefreshToken string `json:"refresh_token,omitempty"` | |||||
| AccountID string `json:"account_id,omitempty"` | |||||
| LastRefresh string `json:"last_refresh,omitempty"` | |||||
| Email string `json:"email,omitempty"` | |||||
| Type string `json:"type,omitempty"` | |||||
| Expired string `json:"expired,omitempty"` | |||||
| } | |||||
| func parseCodexOAuthKey(raw string) (*CodexOAuthKey, error) { | |||||
| if strings.TrimSpace(raw) == "" { | |||||
| return nil, errors.New("codex channel: empty oauth key") | |||||
| } | |||||
| var key CodexOAuthKey | |||||
| if err := common.Unmarshal([]byte(raw), &key); err != nil { | |||||
| return nil, errors.New("codex channel: invalid oauth key json") | |||||
| } | |||||
| return &key, nil | |||||
| } | |||||
| type CodexOAuthKey = common.CodexOAuthCredential | |||||
| func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts CodexCredentialRefreshOptions) (*CodexOAuthKey, *model.Channel, error) { | func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts CodexCredentialRefreshOptions) (*CodexOAuthKey, *model.Channel, error) { | ||||
| ch, err := model.GetChannelById(channelID, true) | ch, err := model.GetChannelById(channelID, true) | ||||
| @@ -51,7 +29,7 @@ func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts Code | |||||
| return nil, nil, fmt.Errorf("channel type is not Codex") | return nil, nil, fmt.Errorf("channel type is not Codex") | ||||
| } | } | ||||
| oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(ch.Key)) | |||||
| oauthKey, err := common.ParseCodexOAuthCredential(strings.TrimSpace(ch.Key)) | |||||
| if err != nil { | if err != nil { | ||||
| return nil, nil, err | return nil, nil, err | ||||
| } | } | ||||
| @@ -93,7 +93,7 @@ func runCodexCredentialAutoRefreshOnce() { | |||||
| continue | continue | ||||
| } | } | ||||
| oauthKey, err := parseCodexOAuthKey(rawKey) | |||||
| oauthKey, err := common.ParseCodexOAuthCredential(rawKey) | |||||
| if err != nil { | if err != nil { | ||||
| continue | continue | ||||
| } | } | ||||
| @@ -26,6 +26,10 @@ import { | |||||
| showSuccess, | showSuccess, | ||||
| verifyJSON, | verifyJSON, | ||||
| } from '../../../../helpers'; | } from '../../../../helpers'; | ||||
| import { | |||||
| CODEX_CREDENTIAL_MODE, | |||||
| detectCodexCredentialMode, | |||||
| } from '../../../../helpers/codex'; | |||||
| import { useIsMobile } from '../../../../hooks/common/useIsMobile'; | import { useIsMobile } from '../../../../hooks/common/useIsMobile'; | ||||
| import { CHANNEL_OPTIONS } from '../../../../constants'; | import { CHANNEL_OPTIONS } from '../../../../constants'; | ||||
| import { | import { | ||||
| @@ -235,8 +239,14 @@ const EditChannelModal = (props) => { | |||||
| const [isIonetChannel, setIsIonetChannel] = useState(false); | const [isIonetChannel, setIsIonetChannel] = useState(false); | ||||
| const [ionetMetadata, setIonetMetadata] = useState(null); | const [ionetMetadata, setIonetMetadata] = useState(null); | ||||
| const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false); | const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false); | ||||
| const [codexCredentialMode, setCodexCredentialMode] = useState( | |||||
| CODEX_CREDENTIAL_MODE.API_KEY, | |||||
| ); | |||||
| const [codexCredentialRefreshing, setCodexCredentialRefreshing] = | const [codexCredentialRefreshing, setCodexCredentialRefreshing] = | ||||
| useState(false); | useState(false); | ||||
| const isCodexOAuthMode = | |||||
| inputs.type === 57 && | |||||
| codexCredentialMode === CODEX_CREDENTIAL_MODE.OAUTH; | |||||
| // 密钥显示状态 | // 密钥显示状态 | ||||
| const [keyDisplayState, setKeyDisplayState] = useState({ | const [keyDisplayState, setKeyDisplayState] = useState({ | ||||
| @@ -537,6 +547,7 @@ const EditChannelModal = (props) => { | |||||
| setUseManualInput(false); | setUseManualInput(false); | ||||
| if (value === 57) { | if (value === 57) { | ||||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | |||||
| setBatch(false); | setBatch(false); | ||||
| setMultiToSingle(false); | setMultiToSingle(false); | ||||
| setMultiKeyMode('random'); | setMultiKeyMode('random'); | ||||
| @@ -546,6 +557,8 @@ const EditChannelModal = (props) => { | |||||
| formApiRef.current.setValue('vertex_files', []); | formApiRef.current.setValue('vertex_files', []); | ||||
| } | } | ||||
| setInputs((prev) => ({ ...prev, vertex_files: [] })); | setInputs((prev) => ({ ...prev, vertex_files: [] })); | ||||
| } else { | |||||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | |||||
| } | } | ||||
| } | } | ||||
| //setAutoBan | //setAutoBan | ||||
| @@ -689,6 +702,12 @@ const EditChannelModal = (props) => { | |||||
| data.base_url = 'https://ark.cn-beijing.volces.com'; | data.base_url = 'https://ark.cn-beijing.volces.com'; | ||||
| } | } | ||||
| if (data.type === 57) { | |||||
| setCodexCredentialMode(detectCodexCredentialMode(data.key)); | |||||
| } else { | |||||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | |||||
| } | |||||
| setInputs(data); | setInputs(data); | ||||
| if (formApiRef.current) { | if (formApiRef.current) { | ||||
| formApiRef.current.setValues(data); | formApiRef.current.setValues(data); | ||||
| @@ -922,6 +941,7 @@ const EditChannelModal = (props) => { | |||||
| }; | }; | ||||
| const handleCodexOAuthGenerated = (key) => { | const handleCodexOAuthGenerated = (key) => { | ||||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.OAUTH); | |||||
| handleInputChange('key', key); | handleInputChange('key', key); | ||||
| formatJsonField('key'); | formatJsonField('key'); | ||||
| }; | }; | ||||
| @@ -1073,6 +1093,7 @@ const EditChannelModal = (props) => { | |||||
| setIsEnterpriseAccount(false); | setIsEnterpriseAccount(false); | ||||
| // 重置豆包隐藏入口状态 | // 重置豆包隐藏入口状态 | ||||
| setDoubaoApiEditUnlocked(false); | setDoubaoApiEditUnlocked(false); | ||||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | |||||
| doubaoApiClickCountRef.current = 0; | doubaoApiClickCountRef.current = 0; | ||||
| // 清空表单中的key_mode字段 | // 清空表单中的key_mode字段 | ||||
| if (formApiRef.current) { | if (formApiRef.current) { | ||||
| @@ -1235,6 +1256,9 @@ const EditChannelModal = (props) => { | |||||
| } | } | ||||
| if (rawKey !== '') { | if (rawKey !== '') { | ||||
| if (codexCredentialMode === CODEX_CREDENTIAL_MODE.API_KEY) { | |||||
| localInputs.key = rawKey; | |||||
| } else { | |||||
| if (!verifyJSON(rawKey)) { | if (!verifyJSON(rawKey)) { | ||||
| showInfo(t('密钥必须是合法的 JSON 格式!')); | showInfo(t('密钥必须是合法的 JSON 格式!')); | ||||
| return; | return; | ||||
| @@ -1260,6 +1284,7 @@ const EditChannelModal = (props) => { | |||||
| showInfo(t('密钥必须是合法的 JSON 格式!')); | showInfo(t('密钥必须是合法的 JSON 格式!')); | ||||
| return; | return; | ||||
| } | } | ||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -2137,6 +2162,32 @@ const EditChannelModal = (props) => { | |||||
| <> | <> | ||||
| {inputs.type === 57 ? ( | {inputs.type === 57 ? ( | ||||
| <> | <> | ||||
| <Form.Select | |||||
| field='codex_credential_mode' | |||||
| label={t('凭证方式')} | |||||
| optionList={[ | |||||
| { | |||||
| label: 'API Key', | |||||
| value: CODEX_CREDENTIAL_MODE.API_KEY, | |||||
| }, | |||||
| { | |||||
| label: 'OAuth', | |||||
| value: CODEX_CREDENTIAL_MODE.OAUTH, | |||||
| }, | |||||
| ]} | |||||
| value={codexCredentialMode} | |||||
| onChange={(value) => setCodexCredentialMode(value)} | |||||
| style={{ width: '100%' }} | |||||
| extraText={ | |||||
| isCodexOAuthMode | |||||
| ? t( | |||||
| 'OAuth 模式需要包含 access_token 和 account_id 的 JSON 凭据', | |||||
| ) | |||||
| : t( | |||||
| 'API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权', | |||||
| ) | |||||
| } | |||||
| /> | |||||
| <Form.TextArea | <Form.TextArea | ||||
| field='key' | field='key' | ||||
| label={ | label={ | ||||
| @@ -2164,13 +2215,22 @@ const EditChannelModal = (props) => { | |||||
| disabled={isIonetLocked} | disabled={isIonetLocked} | ||||
| extraText={ | extraText={ | ||||
| <div className='flex flex-col gap-2'> | <div className='flex flex-col gap-2'> | ||||
| <Text type='tertiary' size='small'> | |||||
| {isCodexOAuthMode ? ( | |||||
| <Text type='tertiary' size='small'> | |||||
| {t( | {t( | ||||
| '仅支持 JSON 对象,必须包含 access_token 与 account_id', | '仅支持 JSON 对象,必须包含 access_token 与 account_id', | ||||
| )} | )} | ||||
| </Text> | |||||
| </Text> | |||||
| ) : ( | |||||
| <Text type='tertiary' size='small'> | |||||
| {t( | |||||
| 'API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权', | |||||
| )} | |||||
| </Text> | |||||
| )} | |||||
| <Space wrap spacing='tight'> | |||||
| {isCodexOAuthMode && ( | |||||
| <Space wrap spacing='tight'> | |||||
| <Button | <Button | ||||
| size='small' | size='small' | ||||
| type='primary' | type='primary' | ||||
| @@ -2215,7 +2275,8 @@ const EditChannelModal = (props) => { | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| {batchExtra} | {batchExtra} | ||||
| </Space> | |||||
| </Space> | |||||
| )} | |||||
| </div> | </div> | ||||
| } | } | ||||
| autosize | autosize | ||||
| @@ -187,7 +187,7 @@ export const CHANNEL_OPTIONS = [ | |||||
| { | { | ||||
| value: 57, | value: 57, | ||||
| color: 'blue', | color: 'blue', | ||||
| label: 'Codex (OpenAI OAuth)', | |||||
| label: 'Codex', | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| @@ -0,0 +1,31 @@ | |||||
| export const CODEX_CREDENTIAL_MODE = { | |||||
| API_KEY: 'api_key', | |||||
| OAUTH: 'oauth', | |||||
| }; | |||||
| export function isCodexOAuthCredential(rawKey) { | |||||
| const trimmed = String(rawKey || '').trim(); | |||||
| if (!trimmed.startsWith('{')) { | |||||
| return false; | |||||
| } | |||||
| try { | |||||
| const parsed = JSON.parse(trimmed); | |||||
| if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { | |||||
| return false; | |||||
| } | |||||
| return ( | |||||
| String(parsed.access_token || '').trim() !== '' && | |||||
| String(parsed.account_id || '').trim() !== '' | |||||
| ); | |||||
| } catch { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| export function detectCodexCredentialMode(rawKey) { | |||||
| return isCodexOAuthCredential(rawKey) | |||||
| ? CODEX_CREDENTIAL_MODE.OAUTH | |||||
| : CODEX_CREDENTIAL_MODE.API_KEY; | |||||
| } | |||||
| @@ -28,6 +28,7 @@ import { | |||||
| copy, | copy, | ||||
| toBoolean, | toBoolean, | ||||
| } from '../../helpers'; | } from '../../helpers'; | ||||
| import { isCodexOAuthCredential } from '../../helpers/codex'; | |||||
| import { | import { | ||||
| CHANNEL_OPTIONS, | CHANNEL_OPTIONS, | ||||
| ITEMS_PER_PAGE, | ITEMS_PER_PAGE, | ||||
| @@ -747,7 +748,7 @@ export const useChannelsData = () => { | |||||
| }; | }; | ||||
| const updateChannelBalance = async (record) => { | const updateChannelBalance = async (record) => { | ||||
| if (record?.type === 57) { | |||||
| if (record?.type === 57 && isCodexOAuthCredential(record?.key)) { | |||||
| openCodexUsageModal({ | openCodexUsageModal({ | ||||
| t, | t, | ||||
| record, | record, | ||||
| @@ -38,9 +38,15 @@ | |||||
| "AI编程": "AI Coding", | "AI编程": "AI Coding", | ||||
| "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key": "AK/SK mode uses AccessKey and SecretAccessKey; API Key mode uses an API Key", | "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key": "AK/SK mode uses AccessKey and SecretAccessKey; API Key mode uses an API Key", | ||||
| "API Key": "API Key", | "API Key": "API Key", | ||||
| "API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权": "API Key mode uses a plain-text secret and standard Bearer authentication", | |||||
| "API Key 模式下不支持批量创建": "Batch creation not supported in API Key mode", | "API Key 模式下不支持批量创建": "Batch creation not supported in API Key mode", | ||||
| "API Key 验证失败": "API Key verification failed", | "API Key 验证失败": "API Key verification failed", | ||||
| "API Key 验证成功!连接到 io.net 服务正常": "API Key verification successful! Connection to io.net service is normal", | "API Key 验证成功!连接到 io.net 服务正常": "API Key verification successful! Connection to io.net service is normal", | ||||
| "OAuth 模式需要包含 access_token 和 account_id 的 JSON 凭据": "OAuth mode requires a JSON credential containing access_token and account_id", | |||||
| "OAuth 凭据必须包含 access_token 和 account_id": "OAuth credential must include access_token and account_id", | |||||
| "OAuth 凭据必须是合法的 JSON 对象": "OAuth credential must be a valid JSON object", | |||||
| "凭证方式": "Credential Mode", | |||||
| "请输入 Codex API Key": "Enter the Codex API Key", | |||||
| "API 地址和相关配置": "API URL and related configuration", | "API 地址和相关配置": "API URL and related configuration", | ||||
| "API 密钥": "API Key", | "API 密钥": "API Key", | ||||
| "API 文档": "API Documentation", | "API 文档": "API Documentation", | ||||
| @@ -3117,4 +3123,4 @@ | |||||
| "必须以 @ 开头,如 @company.com、@edu.cn": "Must start with @, e.g. @company.com, @edu.cn", | "必须以 @ 开头,如 @company.com、@edu.cn": "Must start with @, e.g. @company.com, @edu.cn", | ||||
| "匹配该后缀的用户注册时获得的初始额度": "Initial quota for users registering with this email suffix", | "匹配该后缀的用户注册时获得的初始额度": "Initial quota for users registering with this email suffix", | ||||
| "确认删除该规则?": "Confirm delete this rule?" | "确认删除该规则?": "Confirm delete this rule?" | ||||
| } | |||||
| } | |||||
| @@ -35,9 +35,15 @@ | |||||
| "AI编程": "AI编程", | "AI编程": "AI编程", | ||||
| "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key": "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key", | "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key": "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key", | ||||
| "API Key": "API Key", | "API Key": "API Key", | ||||
| "API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权": "API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权", | |||||
| "API Key 模式下不支持批量创建": "API Key 模式下不支持批量创建", | "API Key 模式下不支持批量创建": "API Key 模式下不支持批量创建", | ||||
| "API Key 验证失败": "API Key 验证失败", | "API Key 验证失败": "API Key 验证失败", | ||||
| "API Key 验证成功!连接到 io.net 服务正常": "API Key 验证成功!连接到 io.net 服务正常", | "API Key 验证成功!连接到 io.net 服务正常": "API Key 验证成功!连接到 io.net 服务正常", | ||||
| "OAuth 模式需要包含 access_token 和 account_id 的 JSON 凭据": "OAuth 模式需要包含 access_token 和 account_id 的 JSON 凭据", | |||||
| "OAuth 凭据必须包含 access_token 和 account_id": "OAuth 凭据必须包含 access_token 和 account_id", | |||||
| "OAuth 凭据必须是合法的 JSON 对象": "OAuth 凭据必须是合法的 JSON 对象", | |||||
| "凭证方式": "凭证方式", | |||||
| "请输入 Codex API Key": "请输入 Codex API Key", | |||||
| "API 地址和相关配置": "API 地址和相关配置", | "API 地址和相关配置": "API 地址和相关配置", | ||||
| "API 密钥": "API 密钥", | "API 密钥": "API 密钥", | ||||
| "API 文档": "API 文档", | "API 文档": "API 文档", | ||||
| @@ -3094,4 +3100,4 @@ | |||||
| "必须以 @ 开头,如 @company.com、@edu.cn": "必须以 @ 开头,如 @company.com、@edu.cn", | "必须以 @ 开头,如 @company.com、@edu.cn": "必须以 @ 开头,如 @company.com、@edu.cn", | ||||
| "匹配该后缀的用户注册时获得的初始额度": "匹配该后缀的用户注册时获得的初始额度", | "匹配该后缀的用户注册时获得的初始额度": "匹配该后缀的用户注册时获得的初始额度", | ||||
| "确认删除该规则?": "确认删除该规则?" | "确认删除该规则?": "确认删除该规则?" | ||||
| } | |||||
| } | |||||