diff --git a/controller/channel.go b/controller/channel.go index a2b5687a..2438e59d 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -15,7 +15,6 @@ import ( "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/model" relaychannel "github.com/QuantumNous/new-api/relay/channel" - "github.com/QuantumNous/new-api/relay/channel/gemini" "github.com/QuantumNous/new-api/relay/channel/ollama" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service/authz" @@ -1176,102 +1175,27 @@ func FetchModels(c *gin.Context) { baseURL = constant.ChannelBaseURLs[req.Type] } - // remove line breaks and extra spaces. key := strings.TrimSpace(req.Key) - key = strings.Split(key, "\n")[0] - - if req.Type == constant.ChannelTypeOllama { - models, err := ollama.FetchOllamaModels(baseURL, key) - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()), - }) - return - } - - names := make([]string, 0, len(models)) - for _, modelInfo := range models { - names = append(names, modelInfo.Name) - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": names, - }) - return + if req.Type != constant.ChannelTypeCodex { + key = strings.Split(key, "\n")[0] } - if req.Type == constant.ChannelTypeGemini { - models, err := gemini.FetchGeminiModels(baseURL, key, "") - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()), - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": models, - }) - return + channel := &model.Channel{ + Type: req.Type, + Key: key, + BaseURL: &baseURL, } - - client := &http.Client{} - url := fmt.Sprintf("%s/v1/models", baseURL) - - request, err := http.NewRequest("GET", url, nil) + models, err := fetchChannelUpstreamModelIDs(channel) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ + c.JSON(http.StatusOK, gin.H{ "success": false, - "message": err.Error(), + "message": fmt.Sprintf("获取模型列表失败: %s", err.Error()), }) return } - - request.Header.Set("Authorization", "Bearer "+key) - - response, err := client.Do(request) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - //check status code - if response.StatusCode != http.StatusOK { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "Failed to fetch models", - }) - return - } - defer response.Body.Close() - - var result struct { - Data []struct { - ID string `json:"id"` - } `json:"data"` - } - - if err := json.NewDecoder(response.Body).Decode(&result); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - - var models []string - for _, model := range result.Data { - models = append(models, model.ID) - } - c.JSON(http.StatusOK, gin.H{ "success": true, + "message": "", "data": models, }) } diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 122a9f6b..bfcb8cdb 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -285,6 +285,10 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { return normalizeModelNames(models), nil } + if channel.Type == constant.ChannelTypeCodex { + return service.FetchCodexChannelModels(channel) + } + var url string switch channel.Type { case constant.ChannelTypeAli: diff --git a/controller/channel_upstream_update_test.go b/controller/channel_upstream_update_test.go index f0dfc5a9..1f353d7b 100644 --- a/controller/channel_upstream_update_test.go +++ b/controller/channel_upstream_update_test.go @@ -3,14 +3,51 @@ package controller import ( "net/http" "net/http/httptest" + "strings" "testing" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +func TestFetchModelsUsesSharedChannelFetchBehavior(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("x-api-key") != "first-key" { + t.Errorf("unexpected x-api-key header: %s", r.Header.Get("x-api-key")) + } + if r.Header.Get("Authorization") != "" { + t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":" claude-sonnet "},{"id":"claude-sonnet"}]}`)) + })) + t.Cleanup(server.Close) + + body, err := common.Marshal(map[string]any{ + "base_url": server.URL, + "type": constant.ChannelTypeAnthropic, + "key": "first-key\nsecond-key", + }) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", strings.NewReader(string(body))) + ctx.Request.Header.Set("Content-Type", "application/json") + + FetchModels(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{"success":true,"message":"","data":["claude-sonnet"]}`, recorder.Body.String()) +} + func TestNormalizeModelNames(t *testing.T) { result := normalizeModelNames([]string{ " gpt-4o ", diff --git a/relay/channel/codex/constants.go b/relay/channel/codex/constants.go index 61a37310..8e6597cd 100644 --- a/relay/channel/codex/constants.go +++ b/relay/channel/codex/constants.go @@ -1,26 +1,27 @@ package codex import ( + "slices" + "github.com/QuantumNous/new-api/setting/ratio_setting" - "github.com/samber/lo" ) var baseModelList = []string{ - "gpt-5", "gpt-5-codex", "gpt-5-codex-mini", - "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini", - "gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark", - "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex-spark", + "codex-auto-review", } -var ModelList = withCompactModelSuffix(baseModelList) +var ModelList = slices.DeleteFunc( + ratio_setting.WithCompactModelVariants(baseModelList), + func(modelName string) bool { + return modelName == ratio_setting.WithCompactModelSuffix("codex-auto-review") + }, +) const ChannelName = "codex" - -func withCompactModelSuffix(models []string) []string { - out := make([]string, 0, len(models)*2) - out = append(out, models...) - out = append(out, lo.Map(models, func(model string, _ int) string { - return ratio_setting.WithCompactModelSuffix(model) - })...) - return lo.Uniq(out) -} diff --git a/service/codex_channel_models.go b/service/codex_channel_models.go new file mode 100644 index 00000000..e4c8f04e --- /dev/null +++ b/service/codex_channel_models.go @@ -0,0 +1,90 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/ratio_setting" +) + +func FetchCodexChannelModels(channel *model.Channel) ([]string, error) { + if channel == nil || channel.Type != constant.ChannelTypeCodex { + return nil, fmt.Errorf("channel type is not Codex") + } + if channel.ChannelInfo.IsMultiKey { + return nil, fmt.Errorf("codex channel does not support multi-key model discovery") + } + + client, err := NewProxyHttpClient(channel.GetSetting().Proxy) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + clientVersion, err := GetLatestCodexClientVersion(ctx, client) + if err != nil { + return nil, fmt.Errorf("failed to get Codex client version: %w", err) + } + + baseURL := channel.GetBaseURL() + if baseURL == "" { + baseURL = constant.ChannelBaseURLs[constant.ChannelTypeCodex] + } + return fetchCodexChannelModels(ctx, channel, baseURL, client, clientVersion) +} + +func fetchCodexChannelModels( + ctx context.Context, + channel *model.Channel, + baseURL string, + client *http.Client, + clientVersion string, +) ([]string, error) { + oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(channel.Key)) + if err != nil { + return nil, err + } + + statusCode, models, err := FetchCodexModels(ctx, client, baseURL, oauthKey, clientVersion) + if err != nil { + return nil, err + } + if statusCode == http.StatusUnauthorized { + if channel.Id <= 0 { + return nil, fmt.Errorf("codex channel credential expired; save the channel before retrying model fetch") + } + refreshedKey, _, refreshErr := RefreshCodexChannelCredential( + ctx, + channel.Id, + CodexCredentialRefreshOptions{ResetCaches: true}, + ) + if refreshErr != nil { + return nil, fmt.Errorf("failed to refresh Codex channel credential: %w", refreshErr) + } + statusCode, models, err = FetchCodexModels(ctx, client, baseURL, &CodexOAuthKey{ + AccessToken: refreshedKey.AccessToken, + AccountID: refreshedKey.AccountID, + }, clientVersion) + if err != nil { + return nil, err + } + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("upstream status: %d", statusCode) + } + modelVariants := make([]string, 0, len(models)*2) + modelVariants = append(modelVariants, models...) + for _, modelName := range models { + if modelName == "codex-auto-review" { + continue + } + modelVariants = append(modelVariants, ratio_setting.WithCompactModelSuffix(modelName)) + } + return modelVariants, nil +} diff --git a/service/codex_models.go b/service/codex_models.go new file mode 100644 index 00000000..f7ffb983 --- /dev/null +++ b/service/codex_models.go @@ -0,0 +1,180 @@ +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" +) + +const ( + codexLatestReleaseURL = "https://api.github.com/repos/openai/codex/releases/latest" + codexClientVersionCacheTTL = time.Hour +) + +type codexClientVersionCache struct { + sync.Mutex + version string + expiresAt time.Time +} + +var latestCodexClientVersion codexClientVersionCache + +func GetLatestCodexClientVersion(ctx context.Context, client *http.Client) (string, error) { + return latestCodexClientVersion.get(ctx, client, codexLatestReleaseURL, time.Now()) +} + +func (cache *codexClientVersionCache) get(ctx context.Context, client *http.Client, releaseURL string, now time.Time) (string, error) { + cache.Lock() + defer cache.Unlock() + + if cache.version != "" && now.Before(cache.expiresAt) { + return cache.version, nil + } + + version, err := fetchLatestCodexClientVersion(ctx, client, releaseURL) + if err != nil { + if cache.version != "" { + cache.expiresAt = now.Add(codexClientVersionCacheTTL) + return cache.version, nil + } + return "", err + } + + cache.version = version + cache.expiresAt = now.Add(codexClientVersionCacheTTL) + return version, nil +} + +func fetchLatestCodexClientVersion(ctx context.Context, client *http.Client, releaseURL string) (string, error) { + if client == nil { + return "", fmt.Errorf("nil http client") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "new-api") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("codex release lookup failed: status=%d", resp.StatusCode) + } + + var release struct { + Name string `json:"name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + } + if err := common.DecodeJson(resp.Body, &release); err != nil { + return "", err + } + if release.Draft || release.Prerelease { + return "", fmt.Errorf("latest codex release is not stable") + } + version := strings.TrimSpace(release.Name) + if version == "" { + return "", fmt.Errorf("latest codex release has no version name") + } + return version, nil +} + +func FetchCodexModels( + ctx context.Context, + client *http.Client, + baseURL string, + oauthKey *CodexOAuthKey, + clientVersion string, +) (statusCode int, models []string, err error) { + if client == nil { + return 0, nil, fmt.Errorf("nil http client") + } + if oauthKey == nil { + return 0, nil, fmt.Errorf("nil oauth key") + } + + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + accessToken := strings.TrimSpace(oauthKey.AccessToken) + accountID := strings.TrimSpace(oauthKey.AccountID) + clientVersion = strings.TrimSpace(clientVersion) + if baseURL == "" { + return 0, nil, fmt.Errorf("empty baseURL") + } + if accessToken == "" { + return 0, nil, fmt.Errorf("codex channel: access_token is required") + } + if accountID == "" { + return 0, nil, fmt.Errorf("codex channel: account_id is required") + } + if clientVersion == "" { + return 0, nil, fmt.Errorf("codex channel: client_version is required") + } + + modelsURL, err := url.Parse(baseURL + "/backend-api/codex/models") + if err != nil { + return 0, nil, err + } + query := modelsURL.Query() + query.Set("client_version", clientVersion) + modelsURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL.String(), nil) + if err != nil { + return 0, nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("ChatGPT-Account-Id", accountID) + req.Header.Set("User-Agent", "codex-cli/"+clientVersion) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return resp.StatusCode, nil, nil + } + + var result struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` + } + if err := common.Unmarshal(body, &result); err != nil { + return resp.StatusCode, nil, err + } + + seen := make(map[string]struct{}, len(result.Models)) + models = make([]string, 0, len(result.Models)) + for _, item := range result.Models { + slug := strings.TrimSpace(item.Slug) + if slug == "" { + continue + } + if _, ok := seen[slug]; ok { + continue + } + seen[slug] = struct{}{} + models = append(models, slug) + } + return resp.StatusCode, models, nil +} diff --git a/setting/ratio_setting/compact_suffix.go b/setting/ratio_setting/compact_suffix.go index 2d2fe3c3..b1fb6465 100644 --- a/setting/ratio_setting/compact_suffix.go +++ b/setting/ratio_setting/compact_suffix.go @@ -11,3 +11,24 @@ func WithCompactModelSuffix(modelName string) string { } return modelName + CompactModelSuffix } + +func WithCompactModelVariants(models []string) []string { + variants := make([]string, 0, len(models)*2) + seen := make(map[string]struct{}, len(models)*2) + for _, model := range models { + if _, ok := seen[model]; ok { + continue + } + seen[model] = struct{}{} + variants = append(variants, model) + } + for _, model := range models { + compactModel := WithCompactModelSuffix(model) + if _, ok := seen[compactModel]; ok { + continue + } + seen[compactModel] = struct{}{} + variants = append(variants, compactModel) + } + return variants +} diff --git a/web/default/src/features/channels/constants.ts b/web/default/src/features/channels/constants.ts index 877308b2..d95e6a41 100644 --- a/web/default/src/features/channels/constants.ts +++ b/web/default/src/features/channels/constants.ts @@ -377,7 +377,7 @@ export const FIELD_DESCRIPTIONS = { // ============================================================================ export const MODEL_FETCHABLE_TYPES = new Set([ - 1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, + 1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, ]) export const TYPE_TO_KEY_PROMPT: Record = {