Procházet zdrojové kódy

feat: support codex api key credentials

master
fengsilin před 1 týdnem
rodič
revize
332d5a62f2
12 změnil soubory, kde provedl 316 přidání a 59 odebrání
  1. +58
    -0
      common/codex_credential.go
  2. +23
    -16
      controller/channel.go
  3. +111
    -0
      controller/codex_channel_test.go
  4. +8
    -10
      controller/codex_usage.go
  5. +2
    -24
      service/codex_credential_refresh.go
  6. +1
    -1
      service/codex_credential_refresh_task.go
  7. +65
    -4
      web/src/components/table/channels/modals/EditChannelModal.jsx
  8. +1
    -1
      web/src/constants/channel.constants.js
  9. +31
    -0
      web/src/helpers/codex.js
  10. +2
    -1
      web/src/hooks/channels/useChannelsData.jsx
  11. +7
    -1
      web/src/i18n/locales/en.json
  12. +7
    -1
      web/src/i18n/locales/zh-CN.json

+ 58
- 0
common/codex_credential.go Zobrazit soubor

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

+ 23
- 16
controller/channel.go Zobrazit soubor

@@ -3,6 +3,7 @@ package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
@@ -615,20 +616,22 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
// Codex OAuth key validation (optional, only when JSON object is provided)
if channel.Type == constant.ChannelTypeCodex {
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")
}
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})
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())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"})
return
@@ -2112,11 +2119,11 @@ func GetUserChannelsForBinding(c *gin.Context) {
result := make([]gin.H, 0, len(channels))
for _, ch := range channels {
result = append(result, gin.H{
"id": ch.Id,
"name": ch.Name,
"id": ch.Id,
"name": ch.Name,
"public_name": ch.PublicName,
"type": ch.Type,
"remark": ch.Remark,
"type": ch.Type,
"remark": ch.Remark,
})
}



+ 111
- 0
controller/codex_channel_test.go Zobrazit soubor

@@ -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(), "当前凭证方式不支持查看用量")
}

+ 8
- 10
controller/codex_usage.go Zobrazit soubor

@@ -2,6 +2,7 @@ package controller

import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
@@ -11,7 +12,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/codex"
"github.com/QuantumNous/new-api/service"

"github.com/gin-gonic/gin"
@@ -42,22 +42,19 @@ func GetCodexChannelUsage(c *gin.Context) {
return
}

oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key))
oauthKey, err := common.ParseCodexOAuthCredential(strings.TrimSpace(ch.Key))
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())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"})
return
}

accessToken := strings.TrimSpace(oauthKey.AccessToken)
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)
if err != nil {
@@ -98,6 +95,7 @@ func GetCodexChannelUsage(c *gin.Context) {

ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel2()

statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID)
if err != nil {
common.SysError("failed to fetch codex usage after refresh: " + err.Error())


+ 2
- 24
service/codex_credential_refresh.go Zobrazit soubor

@@ -2,7 +2,6 @@ package service

import (
"context"
"errors"
"fmt"
"strings"
"time"
@@ -16,28 +15,7 @@ type CodexCredentialRefreshOptions struct {
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) {
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")
}

oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(ch.Key))
oauthKey, err := common.ParseCodexOAuthCredential(strings.TrimSpace(ch.Key))
if err != nil {
return nil, nil, err
}


+ 1
- 1
service/codex_credential_refresh_task.go Zobrazit soubor

@@ -93,7 +93,7 @@ func runCodexCredentialAutoRefreshOnce() {
continue
}

oauthKey, err := parseCodexOAuthKey(rawKey)
oauthKey, err := common.ParseCodexOAuthCredential(rawKey)
if err != nil {
continue
}


+ 65
- 4
web/src/components/table/channels/modals/EditChannelModal.jsx Zobrazit soubor

@@ -26,6 +26,10 @@ import {
showSuccess,
verifyJSON,
} from '../../../../helpers';
import {
CODEX_CREDENTIAL_MODE,
detectCodexCredentialMode,
} from '../../../../helpers/codex';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import { CHANNEL_OPTIONS } from '../../../../constants';
import {
@@ -235,8 +239,14 @@ const EditChannelModal = (props) => {
const [isIonetChannel, setIsIonetChannel] = useState(false);
const [ionetMetadata, setIonetMetadata] = useState(null);
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
const [codexCredentialMode, setCodexCredentialMode] = useState(
CODEX_CREDENTIAL_MODE.API_KEY,
);
const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
useState(false);
const isCodexOAuthMode =
inputs.type === 57 &&
codexCredentialMode === CODEX_CREDENTIAL_MODE.OAUTH;

// 密钥显示状态
const [keyDisplayState, setKeyDisplayState] = useState({
@@ -537,6 +547,7 @@ const EditChannelModal = (props) => {
setUseManualInput(false);

if (value === 57) {
setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY);
setBatch(false);
setMultiToSingle(false);
setMultiKeyMode('random');
@@ -546,6 +557,8 @@ const EditChannelModal = (props) => {
formApiRef.current.setValue('vertex_files', []);
}
setInputs((prev) => ({ ...prev, vertex_files: [] }));
} else {
setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY);
}
}
//setAutoBan
@@ -689,6 +702,12 @@ const EditChannelModal = (props) => {
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);
if (formApiRef.current) {
formApiRef.current.setValues(data);
@@ -922,6 +941,7 @@ const EditChannelModal = (props) => {
};

const handleCodexOAuthGenerated = (key) => {
setCodexCredentialMode(CODEX_CREDENTIAL_MODE.OAUTH);
handleInputChange('key', key);
formatJsonField('key');
};
@@ -1073,6 +1093,7 @@ const EditChannelModal = (props) => {
setIsEnterpriseAccount(false);
// 重置豆包隐藏入口状态
setDoubaoApiEditUnlocked(false);
setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY);
doubaoApiClickCountRef.current = 0;
// 清空表单中的key_mode字段
if (formApiRef.current) {
@@ -1235,6 +1256,9 @@ const EditChannelModal = (props) => {
}

if (rawKey !== '') {
if (codexCredentialMode === CODEX_CREDENTIAL_MODE.API_KEY) {
localInputs.key = rawKey;
} else {
if (!verifyJSON(rawKey)) {
showInfo(t('密钥必须是合法的 JSON 格式!'));
return;
@@ -1260,6 +1284,7 @@ const EditChannelModal = (props) => {
showInfo(t('密钥必须是合法的 JSON 格式!'));
return;
}
}
}
}

@@ -2137,6 +2162,32 @@ const EditChannelModal = (props) => {
<>
{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
field='key'
label={
@@ -2164,13 +2215,22 @@ const EditChannelModal = (props) => {
disabled={isIonetLocked}
extraText={
<div className='flex flex-col gap-2'>
<Text type='tertiary' size='small'>
{isCodexOAuthMode ? (
<Text type='tertiary' size='small'>
{t(
'仅支持 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
size='small'
type='primary'
@@ -2215,7 +2275,8 @@ const EditChannelModal = (props) => {
</Button>
)}
{batchExtra}
</Space>
</Space>
)}
</div>
}
autosize


+ 1
- 1
web/src/constants/channel.constants.js Zobrazit soubor

@@ -187,7 +187,7 @@ export const CHANNEL_OPTIONS = [
{
value: 57,
color: 'blue',
label: 'Codex (OpenAI OAuth)',
label: 'Codex',
},
];



+ 31
- 0
web/src/helpers/codex.js Zobrazit soubor

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

+ 2
- 1
web/src/hooks/channels/useChannelsData.jsx Zobrazit soubor

@@ -28,6 +28,7 @@ import {
copy,
toBoolean,
} from '../../helpers';
import { isCodexOAuthCredential } from '../../helpers/codex';
import {
CHANNEL_OPTIONS,
ITEMS_PER_PAGE,
@@ -747,7 +748,7 @@ export const useChannelsData = () => {
};

const updateChannelBalance = async (record) => {
if (record?.type === 57) {
if (record?.type === 57 && isCodexOAuthCredential(record?.key)) {
openCodexUsageModal({
t,
record,


+ 7
- 1
web/src/i18n/locales/en.json Zobrazit soubor

@@ -38,9 +38,15 @@
"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",
"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 验证失败": "API Key verification failed",
"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 Key",
"API 文档": "API Documentation",
@@ -3117,4 +3123,4 @@
"必须以 @ 开头,如 @company.com、@edu.cn": "Must start with @, e.g. @company.com, @edu.cn",
"匹配该后缀的用户注册时获得的初始额度": "Initial quota for users registering with this email suffix",
"确认删除该规则?": "Confirm delete this rule?"
}
}

+ 7
- 1
web/src/i18n/locales/zh-CN.json Zobrazit soubor

@@ -35,9 +35,15 @@
"AI编程": "AI编程",
"AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key": "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key",
"API Key": "API Key",
"API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权": "API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权",
"API Key 模式下不支持批量创建": "API Key 模式下不支持批量创建",
"API Key 验证失败": "API Key 验证失败",
"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 文档",
@@ -3094,4 +3100,4 @@
"必须以 @ 开头,如 @company.com、@edu.cn": "必须以 @ 开头,如 @company.com、@edu.cn",
"匹配该后缀的用户注册时获得的初始额度": "匹配该后缀的用户注册时获得的初始额度",
"确认删除该规则?": "确认删除该规则?"
}
}

Načítá se…
Zrušit
Uložit