Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3ffc068
feat: add OpenAI video provider failover
ViperCai May 12, 2026
e90a2c1
feat: add OpenAI video provider failover
ViperCai May 26, 2026
42c4d46
docs: record new server access notes
ViperCai May 27, 2026
9a3a371
merge: sync with upstream main (78 commits)
ViperCai May 28, 2026
a3ad449
docs: 2026-05-28 全模型回归验证 + 文档更新
ViperCai May 28, 2026
224d358
docs: fix grok-video-api.md old API key
ViperCai May 28, 2026
ae376ab
feat: add ListenHub image channel
ViperCai Jun 1, 2026
d7c2d7b
feat: add SiliconFlow image models
ViperCai Jun 6, 2026
8783484
chore: ignore local generated artifacts
ViperCai Jun 6, 2026
17aba3d
Merge remote-tracking branch 'origin/main' into feature/openai-video-…
ViperCai Jun 6, 2026
61e1ddb
docs: document upstream merge workflow
ViperCai Jun 6, 2026
bdea10a
docs: record upstream merge deployment
ViperCai Jun 6, 2026
93ab7a7
docs: improve AI-friendly API usage guide
ViperCai Jun 6, 2026
6135dd1
docs: retain internal test API key
ViperCai Jun 6, 2026
e5500a5
fix: stabilize image generation routing
ViperCai Jun 6, 2026
98cb125
docs: clarify model routing
ViperCai Jun 7, 2026
4731254
docs: update image routing guidance
ViperCai Jun 7, 2026
48f8be8
fix: retry image generation on upstream quota errors
ViperCai Jun 8, 2026
f24287c
Merge remote-tracking branch 'origin/main' into feature/openai-video-…
ViperCai Jun 10, 2026
0399d58
fix: tolerate empty channel_info when scanning from database
ViperCai Jun 10, 2026
dbee0f0
docs: record upstream merge and channel_info fix deployment
ViperCai Jun 10, 2026
e46bd6e
fix: classify dash-named gemini 3.x preview models as image generation
ViperCai Jun 10, 2026
5e06210
docs: record listenhub priority drop and gemini dash-name remap to ap…
ViperCai Jun 10, 2026
51d62fc
docs: add multi-channel failover design review and onboarding checklist
ViperCai Jun 10, 2026
b38e056
feat: add quota error cooldown and configurable quota keywords
ViperCai Jun 10, 2026
83b8540
docs: record quota cooldown deployment and mark roadmap items done
ViperCai Jun 10, 2026
c3eea86
feat: add manxiaobai video provider and model pricing
ViperCai Jun 10, 2026
3b8fb58
fix: detect quota errors relayed with upstream error codes
ViperCai Jun 10, 2026
2c558a2
fix: keep priority bucket indexing stable under quota cooldown
ViperCai Jun 10, 2026
b4a01f4
docs: record manxiaobai onboarding and failover verification
ViperCai Jun 10, 2026
909e359
fix: submit manxiaobai video tasks as multipart form
ViperCai Jun 11, 2026
fbed8de
fix: proxy video content via upstream auth endpoint when no result url
ViperCai Jun 11, 2026
e454011
fix: detect self-referential proxy result url in video content relay
ViperCai Jun 11, 2026
b3c2ef9
docs: record manxiaobai activation and full-path verification
ViperCai Jun 11, 2026
c10ec53
docs: refresh routing, pricing and verification records across docs
ViperCai Jun 11, 2026
6ccdc9f
feat: 下游平台余额统一查询与总览接口
ViperCai Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,9 @@ data/
.test
token_estimator_test.go
skills-lock.json
generated-videos/
docs/*.local.md
tmp/
test-*.mp4
web/**/pnpm-lock.yaml
.playwright-mcp
2 changes: 2 additions & 0 deletions common/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeReplicate
case constant.ChannelTypeCodex:
apiType = constant.APITypeCodex
case constant.ChannelTypeListenHub:
apiType = constant.APITypeListenHub
}
if apiType == -1 {
return constant.APITypeOpenAI, false
Expand Down
4 changes: 3 additions & 1 deletion common/endpoint_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
case constant.ChannelTypeXai:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
case constant.ChannelTypeSora:
case constant.ChannelTypeSora, constant.ChannelTypeOpenAIVideo:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
case constant.ChannelTypeListenHub:
endpointTypes = []constant.EndpointType{constant.EndpointTypeImageGeneration}
default:
if IsOpenAIResponseOnlyModel(modelName) {
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
Expand Down
16 changes: 16 additions & 0 deletions common/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,26 @@ var (
"o4-mini-deep-research",
}
ImageGenerationModels = []string{
"dall-e",
"dall-e-3",
"dall-e-2",
"gpt-image-1",
"gpt-image-2",
"gpt-image-2(线路xf)",
"gr-image-2",
"prefix:imagen-",
"gemini-2.5-flash-image",
"gemini-2.5-flash-image-preview",
"gemini-3-pro-image-preview",
"gemini-3.1-flash-image-preview",
"gemini_3.0_pro_image_preview",
"gemini_3.1_flash_image_preview",
"nano-banana",
"nano-banana-hd",
"nano-banana-pro",
"baidu/ernie-image-turbo",
"qwen/qwen-image",
"tongyi-mai/z-image",
"flux-",
"flux.1-",
}
Expand Down
41 changes: 41 additions & 0 deletions common/model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package common

import (
"testing"

"github.com/QuantumNous/new-api/constant"
)

func TestIsImageGenerationModelIncludesConfiguredImageModels(t *testing.T) {
models := []string{
"gpt-image-2(线路XF)",
"gr-image-2",
"gemini-2.5-flash-image",
"gemini-2.5-flash-image-preview",
"gemini-3-pro-image-preview",
"gemini-3.1-flash-image-preview",
"nano-banana",
"nano-banana-hd",
"nano-banana-pro",
}

for _, model := range models {
t.Run(model, func(t *testing.T) {
if !IsImageGenerationModel(model) {
t.Fatalf("IsImageGenerationModel(%q) = false, want true", model)
}
})
}
}

func TestImageModelsEnableImageGenerationEndpoint(t *testing.T) {
endpoints := GetEndpointTypesByChannelType(constant.ChannelTypeOpenAIVideo, "gr-image-2")
if len(endpoints) == 0 || endpoints[0] != constant.EndpointTypeImageGeneration {
t.Fatalf("OpenAI video image model endpoints = %#v, want image generation first", endpoints)
}

endpoints = GetEndpointTypesByChannelType(constant.ChannelTypeOpenAI, "nano-banana-pro")
if len(endpoints) == 0 || endpoints[0] != constant.EndpointTypeImageGeneration {
t.Fatalf("OpenAI image model endpoints = %#v, want image generation first", endpoints)
}
}
1 change: 1 addition & 0 deletions constant/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ const (
APITypeMiniMax
APITypeReplicate
APITypeCodex
APITypeListenHub
APITypeDummy // this one is only for count, do not add any channel after this
)
12 changes: 9 additions & 3 deletions constant/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const (
ChannelTypeSora = 55
ChannelTypeReplicate = 56
ChannelTypeCodex = 57
ChannelTypeOpenAIVideo = 58
ChannelTypeListenHub = 59
ChannelTypeDummy // this one is only for count, do not add any channel after this

)
Expand Down Expand Up @@ -115,9 +117,11 @@ var ChannelBaseURLs = []string{
"https://api.vidu.cn", //52
"https://llm.submodel.ai", //53
"https://ark.cn-beijing.volces.com", //54
"https://api.openai.com", //55
"https://api.replicate.com", //56
"https://chatgpt.com", //57
"https://api.openai.com", // 55
"https://api.replicate.com", // 56
"https://chatgpt.com", // 57
"", // 58
"https://api.marswave.ai/openapi", // 59
}

var ChannelTypeNames = map[int]string{
Expand Down Expand Up @@ -175,6 +179,8 @@ var ChannelTypeNames = map[int]string{
ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate",
ChannelTypeCodex: "Codex",
ChannelTypeOpenAIVideo: "OpenAIVideo",
ChannelTypeListenHub: "ListenHub",
}

func GetChannelTypeName(channelType int) string {
Expand Down
1 change: 1 addition & 0 deletions constant/context_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (
ContextKeyChannelCreateTime ContextKey = "channel_create_time"
ContextKeyChannelBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelOther ContextKey = "channel_other"
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
ContextKeyChannelParamOverride ContextKey = "param_override"
Expand Down
30 changes: 27 additions & 3 deletions controller/channel-billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,24 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
if channel.GetBaseURL() == "" {
channel.BaseURL = &baseURL
}
// 优先按 base_url/Other hint 走余额 provider(覆盖中转站,不依赖 channel.Type)
if result, handled, err := queryBalanceByProvider(channel); handled {
if err != nil {
return 0, err
}
switch result.Kind {
case BalanceKindConsoleOnly:
return 0, fmt.Errorf("该上游不支持 API 余额查询,仅 web 控制台可查")
case BalanceKindSpendOnly:
// 拿不到钱包余额,返回累计消费供展示;保持为正避免被余额<=0 自动禁用误伤
if result.Used > 0 {
return result.Used, nil
}
return 1, nil
default:
return result.Remaining, nil
}
}
switch channel.Type {
case constant.ChannelTypeOpenAI:
if channel.GetBaseURL() != "" {
Expand Down Expand Up @@ -444,10 +462,16 @@ func UpdateChannelBalance(c *gin.Context) {
common.ApiError(c, err)
return
}
info := channel.GetOtherInfo()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"balance": balance,
"success": true,
"message": "",
"balance": balance,
"kind": info["balance_kind"],
"unit": info["balance_unit"],
"used": info["balance_used"],
"remaining": info["balance_remaining"],
"provider": info["balance_provider"],
})
}

Expand Down
129 changes: 128 additions & 1 deletion controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
Expand Down Expand Up @@ -190,6 +191,13 @@ func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, e
switch channel.Type {
case constant.ChannelTypeAnthropic:
headers = GetClaudeAuthHeader(key)
case constant.ChannelTypeOpenAIVideo:
if isXBSoraModelsAPI(channel.GetBaseURL()) {
headers = http.Header{}
headers.Set("X-API-Key", key)
} else {
headers = GetAuthHeader(key)
}
default:
headers = GetAuthHeader(key)
}
Expand All @@ -212,6 +220,93 @@ func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, e
return headers, nil
}

func isXBSoraModelsAPI(baseURL string) bool {
baseURL = strings.ToLower(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
return strings.Contains(baseURL, "xb-sora2") ||
strings.Contains(baseURL, "xbsora2") ||
strings.Contains(baseURL, "xb-sora") ||
strings.Contains(baseURL, "xbsora") ||
strings.HasSuffix(baseURL, "/api/v1") ||
strings.HasSuffix(baseURL, "/v1")
Comment on lines +223 to +230

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Over-broad XB-Sora detection can break non-XB video providers.

Classifying any base URL ending with /v1 or /api/v1 as XB-Sora will force X-API-Key + XB-specific parsing for standard OpenAI-compatible providers that still require Bearer auth.

💡 Proposed fix
 func isXBSoraModelsAPI(baseURL string) bool {
 	baseURL = strings.ToLower(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
 	return strings.Contains(baseURL, "xb-sora2") ||
 		strings.Contains(baseURL, "xbsora2") ||
 		strings.Contains(baseURL, "xb-sora") ||
-		strings.Contains(baseURL, "xbsora") ||
-		strings.HasSuffix(baseURL, "/api/v1") ||
-		strings.HasSuffix(baseURL, "/v1")
+		strings.Contains(baseURL, "xbsora")
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/channel.go` around lines 207 - 214, The isXBSoraModelsAPI function
is over-broadly treating any URL ending with "/v1" or "/api/v1" as XB-Sora;
change it to only detect XB-Sora via explicit, unique indicators (e.g.,
substrings "xb-sora", "xbsora", or known XB hostnames) and remove the generic
suffix checks (HasSuffix(baseURL, "/api/v1") and HasSuffix(baseURL, "/v1")).
Update the function isXBSoraModelsAPI so it normalizes the baseURL as before but
returns true only when the baseURL contains the XB-specific tokens ("xb-sora",
"xbsora") or matches a whitelist of XB-specific domains/hostnames; do not infer
XB-Sora from generic "/v1" paths to avoid misclassifying standard
OpenAI-compatible providers.

}

func xbSoraModelsURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(baseURL, "/api/v1") || strings.HasSuffix(baseURL, "/v1") {
return baseURL + "/models"
}
return baseURL + "/api/v1/models"
}

func parseXBSoraModelIDs(body []byte) ([]string, error) {
var result map[string]any
if err := common.Unmarshal(body, &result); err != nil {
return nil, err
}
if !xbSoraFetchCodeOK(result["code"]) {
return nil, fmt.Errorf("xb-sora2 models fetch failed: %s", xbSoraFetchMessage(result))
}
models := findXBSoraModelIDs(result, 0)
if len(models) == 0 {
return nil, fmt.Errorf("xb-sora2 models response contains no models")
}
return models, nil
}

func findXBSoraModelIDs(value any, depth int) []string {
if depth > 4 {
return nil
}
switch v := value.(type) {
case map[string]any:
if models, ok := v["models"].([]any); ok {
ids := make([]string, 0, len(models))
for _, item := range models {
model, ok := item.(map[string]any)
if !ok {
continue
}
id, _ := model["id"].(string)
if strings.TrimSpace(id) != "" {
ids = append(ids, id)
}
}
return ids
}
if data, ok := v["data"]; ok {
return findXBSoraModelIDs(data, depth+1)
}
}
return nil
}

func xbSoraFetchCodeOK(code any) bool {
switch v := code.(type) {
case nil:
return true
case int:
return v == 0 || v == http.StatusOK
case int64:
return v == 0 || v == http.StatusOK
case float64:
return v == 0 || v == http.StatusOK
case string:
v = strings.TrimSpace(v)
return v == "" || v == "0" || v == "0000" || v == "200"
default:
return false
}
}

func xbSoraFetchMessage(result map[string]any) string {
for _, key := range []string{"message", "msg", "error"} {
if message, ok := result[key].(string); ok && strings.TrimSpace(message) != "" {
return message
}
}
return "unknown error"
}

func FetchUpstreamModels(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
Expand Down Expand Up @@ -1056,6 +1151,10 @@ func FetchModels(c *gin.Context) {

client := &http.Client{}
url := fmt.Sprintf("%s/v1/models", baseURL)
useXBSoraModelsAPI := req.Type == constant.ChannelTypeOpenAIVideo && isXBSoraModelsAPI(baseURL)
if useXBSoraModelsAPI {
url = xbSoraModelsURL(baseURL)
}

request, err := http.NewRequest("GET", url, nil)
if err != nil {
Expand All @@ -1066,7 +1165,11 @@ func FetchModels(c *gin.Context) {
return
}

request.Header.Set("Authorization", "Bearer "+key)
if useXBSoraModelsAPI {
request.Header.Set("X-API-Key", key)
} else {
request.Header.Set("Authorization", "Bearer "+key)
}

response, err := client.Do(request)
if err != nil {
Expand All @@ -1086,6 +1189,30 @@ func FetchModels(c *gin.Context) {
}
defer response.Body.Close()

if useXBSoraModelsAPI {
body, err := io.ReadAll(response.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
})
return
}
models, err := parseXBSoraModelIDs(body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": models,
})
return
}

var result struct {
Data []struct {
ID string `json:"id"`
Expand Down
Loading