feat(channel): support Codex upstream model discovery (#6184)

* fix(i18n): clarify Go regex and field passthrough copy

* feat(channel): support Codex upstream model discovery

* Revert "fix(i18n): clarify Go regex and field passthrough copy"

This reverts commit d63d7975db3e34ff44e189112d3c15ad8c24ad88.
This commit is contained in:
Seefs
2026-07-18 13:04:16 +08:00
committed by GitHub
parent 1086038f5f
commit 57746fc972
8 changed files with 359 additions and 102 deletions
+90
View File
@@ -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
}
+180
View File
@@ -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
}