fix/cherry-pick-upstream-bugfixes 包含 3 个提交: - fix: 移植上游关键 bug 修复(SSRF、Claude cache、定价过滤等 11 项) - feat: 移植上游 Param Override 增强系统(9 种新 mode、Header Override、Audit 等) - feat(region-sync): 批量查询用户余额接口,优化同步性能 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>feat/alipay-payment
| @@ -1,6 +1,7 @@ | |||
| package controller | |||
| import ( | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/service" | |||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||
| @@ -8,6 +9,30 @@ import ( | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing { | |||
| if len(pricing) == 0 { | |||
| return pricing | |||
| } | |||
| if len(usableGroup) == 0 { | |||
| return []model.Pricing{} | |||
| } | |||
| filtered := make([]model.Pricing, 0, len(pricing)) | |||
| for _, item := range pricing { | |||
| if common.StringsContains(item.EnableGroup, "all") { | |||
| filtered = append(filtered, item) | |||
| continue | |||
| } | |||
| for _, group := range item.EnableGroup { | |||
| if _, ok := usableGroup[group]; ok { | |||
| filtered = append(filtered, item) | |||
| break | |||
| } | |||
| } | |||
| } | |||
| return filtered | |||
| } | |||
| func GetPricing(c *gin.Context) { | |||
| pricing := model.GetPricing() | |||
| userId, exists := c.Get("id") | |||
| @@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) { | |||
| } | |||
| usableGroup = service.GetUserUsableGroups(group) | |||
| pricing = filterPricingByUsableGroups(pricing, usableGroup) | |||
| // check groupRatio contains usableGroup | |||
| for group := range ratio_setting.GetGroupRatioCopy() { | |||
| if _, ok := usableGroup[group]; !ok { | |||
| @@ -145,6 +145,41 @@ func QueryUserQuota(c *gin.Context) { | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "quota": user.Quota}) | |||
| } | |||
| // BatchQueryUserQuota 批量查询用户余额(供 slave 节点调用) | |||
| func BatchQueryUserQuota(c *gin.Context) { | |||
| var req region_sync.BatchQueryQuotaRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| syncErrorResponse(c, http.StatusBadRequest, "invalid request body") | |||
| return | |||
| } | |||
| if len(req.UserIds) == 0 { | |||
| c.JSON(http.StatusOK, region_sync.BatchQueryQuotaResponse{Success: true, Quotas: []region_sync.QuotaEntry{}}) | |||
| return | |||
| } | |||
| if len(req.UserIds) > 500 { | |||
| syncErrorResponse(c, http.StatusBadRequest, "too many user ids (max 500)") | |||
| return | |||
| } | |||
| var users []model.User | |||
| if err := model.DB.Where("id IN ?", req.UserIds).Find(&users).Error; err != nil { | |||
| syncErrorResponse(c, http.StatusInternalServerError, "database error") | |||
| return | |||
| } | |||
| quotas := make([]region_sync.QuotaEntry, 0, len(users)) | |||
| for _, user := range users { | |||
| quotas = append(quotas, region_sync.QuotaEntry{UserId: user.Id, Quota: user.Quota}) | |||
| } | |||
| c.JSON(http.StatusOK, region_sync.BatchQueryQuotaResponse{ | |||
| Success: true, | |||
| Quotas: quotas, | |||
| }) | |||
| } | |||
| // BatchDeductQuota 批量扣费(供 slave 节点调用 master 节点) | |||
| func BatchDeductQuota(c *gin.Context) { | |||
| var req region_sync.BatchDeductRequest | |||
| @@ -8,10 +8,12 @@ import ( | |||
| "net/url" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/constant" | |||
| "github.com/QuantumNous/new-api/logger" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/service" | |||
| "github.com/QuantumNous/new-api/setting/system_setting" | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| @@ -109,6 +111,13 @@ func VideoProxy(c *gin.Context) { | |||
| return | |||
| } | |||
| fetchSetting := system_setting.GetFetchSetting() | |||
| if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { | |||
| logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err)) | |||
| videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err)) | |||
| return | |||
| } | |||
| resp, err := client.Do(req) | |||
| if err != nil { | |||
| logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error())) | |||
| @@ -121,6 +121,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool { | |||
| if c.Query("alt") == "sse" { | |||
| return true | |||
| } | |||
| // Native Gemini API uses URL action to indicate streaming: | |||
| // /v1beta/models/{model}:streamGenerateContent | |||
| if strings.Contains(c.Request.URL.Path, "streamGenerateContent") { | |||
| return true | |||
| } | |||
| return false | |||
| } | |||
| @@ -387,7 +387,7 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl { | |||
| type MessageImageUrl struct { | |||
| Url string `json:"url"` | |||
| Detail string `json:"detail"` | |||
| Detail string `json:"detail,omitempty"` | |||
| MimeType string | |||
| } | |||
| @@ -743,7 +743,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau | |||
| if common.DebugEnabled { | |||
| common.SysLog("claude response usage is not complete, maybe upstream error") | |||
| } | |||
| claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) | |||
| // 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段 | |||
| fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) | |||
| if claudeInfo.Usage.CompletionTokens == 0 || | |||
| (!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) { | |||
| claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens | |||
| } | |||
| if claudeInfo.Usage.PromptTokens == 0 { | |||
| claudeInfo.Usage.PromptTokens = fallback.PromptTokens | |||
| } | |||
| claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens | |||
| } | |||
| if info.RelayFormat == types.RelayFormatClaude { | |||
| @@ -80,9 +80,9 @@ type AliVideoOutput struct { | |||
| // AliUsage 使用统计 | |||
| type AliUsage struct { | |||
| Duration int `json:"duration,omitempty"` | |||
| VideoCount int `json:"video_count,omitempty"` | |||
| SR int `json:"SR,omitempty"` | |||
| Duration dto.IntValue `json:"duration,omitempty"` | |||
| VideoCount dto.IntValue `json:"video_count,omitempty"` | |||
| SR dto.IntValue `json:"SR,omitempty"` | |||
| } | |||
| type AliMetadata struct { | |||
| @@ -63,6 +63,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { | |||
| } | |||
| return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil | |||
| case relayconstant.RelayModeImagesGenerations: | |||
| if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { | |||
| return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil | |||
| } | |||
| return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil | |||
| default: | |||
| if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { | |||
| @@ -70,7 +70,6 @@ func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, requ | |||
| } | |||
| func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) { | |||
| overrideCtx := relaycommon.BuildParamOverrideContext(info) | |||
| chatJSON, err := common.Marshal(request) | |||
| if err != nil { | |||
| return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) | |||
| @@ -82,7 +81,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad | |||
| } | |||
| if len(info.ParamOverride) > 0 { | |||
| chatJSON, err = relaycommon.ApplyParamOverride(chatJSON, info.ParamOverride, overrideCtx) | |||
| chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info) | |||
| if err != nil { | |||
| return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) | |||
| } | |||
| @@ -153,7 +153,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ | |||
| // apply param override | |||
| if len(info.ParamOverride) > 0 { | |||
| jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) | |||
| jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) | |||
| if err != nil { | |||
| return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) | |||
| } | |||
| @@ -144,6 +144,12 @@ type RelayInfo struct { | |||
| SubscriptionAmountUsedAfterPreConsume int64 | |||
| IsClaudeBetaQuery bool // /v1/messages?beta=true | |||
| IsChannelTest bool // channel test request | |||
| RetryIndex int | |||
| LastError *types.NewAPIError | |||
| RequestHeaders map[string]string | |||
| RuntimeHeadersOverride map[string]interface{} | |||
| UseRuntimeHeadersOverride bool | |||
| ParamOverrideAudit []string | |||
| PriceData types.PriceData | |||
| @@ -473,6 +479,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { | |||
| //promptTokens: common.GetContextKeyInt(c, constant.ContextKeyPromptTokens), | |||
| estimatePromptTokens: common.GetContextKeyInt(c, constant.ContextKeyEstimatedTokens), | |||
| }, | |||
| RequestHeaders: cloneRequestHeaders(c), | |||
| } | |||
| if info.RelayMode == relayconstant.RelayModeUnknown { | |||
| @@ -493,6 +500,27 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { | |||
| return info | |||
| } | |||
| func cloneRequestHeaders(c *gin.Context) map[string]string { | |||
| if c == nil || c.Request == nil { | |||
| return nil | |||
| } | |||
| if len(c.Request.Header) == 0 { | |||
| return nil | |||
| } | |||
| headers := make(map[string]string, len(c.Request.Header)) | |||
| for key := range c.Request.Header { | |||
| value := strings.TrimSpace(c.Request.Header.Get(key)) | |||
| if value == "" { | |||
| continue | |||
| } | |||
| headers[key] = value | |||
| } | |||
| if len(headers) == 0 { | |||
| return nil | |||
| } | |||
| return headers | |||
| } | |||
| func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) { | |||
| var info *RelayInfo | |||
| var err error | |||
| @@ -172,7 +172,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types | |||
| // apply param override | |||
| if len(info.ParamOverride) > 0 { | |||
| jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) | |||
| jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) | |||
| if err != nil { | |||
| return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) | |||
| } | |||
| @@ -52,7 +52,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * | |||
| } | |||
| if len(info.ParamOverride) > 0 { | |||
| jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) | |||
| jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) | |||
| if err != nil { | |||
| return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) | |||
| } | |||
| @@ -157,7 +157,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ | |||
| // apply param override | |||
| if len(info.ParamOverride) > 0 { | |||
| jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) | |||
| jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) | |||
| if err != nil { | |||
| return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) | |||
| } | |||
| @@ -70,7 +70,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type | |||
| // apply param override | |||
| if len(info.ParamOverride) > 0 { | |||
| jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) | |||
| jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) | |||
| if err != nil { | |||
| return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) | |||
| } | |||
| @@ -49,6 +49,13 @@ func RelayMidjourneyImage(c *gin.Context) { | |||
| if httpClient == nil { | |||
| httpClient = service.GetHttpClient() | |||
| } | |||
| fetchSetting := system_setting.GetFetchSetting() | |||
| if err := common.ValidateURLWithFetchSetting(midjourneyTask.ImageUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { | |||
| c.JSON(http.StatusForbidden, gin.H{ | |||
| "error": fmt.Sprintf("request blocked: %v", err), | |||
| }) | |||
| return | |||
| } | |||
| resp, err := httpClient.Get(midjourneyTask.ImageUrl) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{ | |||
| @@ -61,7 +61,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ | |||
| // apply param override | |||
| if len(info.ParamOverride) > 0 { | |||
| jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) | |||
| jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) | |||
| if err != nil { | |||
| return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) | |||
| } | |||
| @@ -96,7 +96,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * | |||
| // apply param override | |||
| if len(info.ParamOverride) > 0 { | |||
| jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) | |||
| jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) | |||
| if err != nil { | |||
| return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) | |||
| } | |||
| @@ -246,7 +246,7 @@ func SetApiRouter(router *gin.Engine) { | |||
| channelRoute.POST("/batch", controller.DeleteChannelBatch) | |||
| channelRoute.POST("/fix", controller.FixChannelsAbilities) | |||
| channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) | |||
| channelRoute.POST("/fetch_models", controller.FetchModels) | |||
| channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels) | |||
| channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth) | |||
| channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) | |||
| channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) | |||
| @@ -396,6 +396,7 @@ func SetApiRouter(router *gin.Engine) { | |||
| syncRoute.POST("/user/create", controller.ReceiveSyncedUserCreate) | |||
| syncRoute.POST("/quota/update", controller.ReceiveQuotaUpdate) | |||
| syncRoute.POST("/quota/query", controller.QueryUserQuota) | |||
| syncRoute.POST("/quota/batch-query", controller.BatchQueryUserQuota) | |||
| syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota) | |||
| syncRoute.GET("/config", controller.GetSyncConfig) | |||
| } | |||
| @@ -142,3 +142,15 @@ func (c *SyncClient) FetchConfig() (*SyncConfigResponse, error) { | |||
| } | |||
| return &resp, nil | |||
| } | |||
| func (c *SyncClient) BatchQueryQuota(userIds []int) (*BatchQueryQuotaResponse, error) { | |||
| data, err := c.doRequest("POST", "/api/internal/sync/quota/batch-query", &BatchQueryQuotaRequest{UserIds: userIds}) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| var resp BatchQueryQuotaResponse | |||
| if err := json.Unmarshal(data, &resp); err != nil { | |||
| return nil, fmt.Errorf("unmarshal response: %w", err) | |||
| } | |||
| return &resp, nil | |||
| } | |||
| @@ -143,7 +143,7 @@ func (m *SyncManager) QueryMasterQuota(remoteUserId int) (int, error) { | |||
| return resp.Quota, nil | |||
| } | |||
| // RunQuotaSync 从 Master 拉取所有同步用户的最新余额,更新本地 synced_quota | |||
| // RunQuotaSync 从 Master 批量拉取所有同步用户的最新余额,更新本地 synced_quota | |||
| func (m *SyncManager) RunQuotaSync() int { | |||
| settings := system_setting.GetRegionSyncSettings() | |||
| if !settings.Enabled || settings.IsMaster { | |||
| @@ -157,24 +157,42 @@ func (m *SyncManager) RunQuotaSync() int { | |||
| logger.LogDebug(nil, "[RegionSync] RunQuotaSync: syncing %d users", len(users)) | |||
| // 建立 remoteUserId -> local User 映射 | |||
| userMap := make(map[int]*model.User, len(users)) | |||
| remoteIds := make([]int, len(users)) | |||
| for i := range users { | |||
| userMap[users[i].RemoteUserId] = &users[i] | |||
| remoteIds[i] = users[i].RemoteUserId | |||
| } | |||
| syncedCount := 0 | |||
| for _, user := range users { | |||
| resp, err := m.client.QueryQuota(user.RemoteUserId) | |||
| if err != nil { | |||
| common.SysError(fmt.Sprintf("[SyncManager] QueryQuota failed for user %d: %v", user.Id, err)) | |||
| continue | |||
| batchSize := 500 | |||
| for i := 0; i < len(remoteIds); i += batchSize { | |||
| end := i + batchSize | |||
| if end > len(remoteIds) { | |||
| end = len(remoteIds) | |||
| } | |||
| if !resp.Success { | |||
| batch := remoteIds[i:end] | |||
| resp, err := m.client.BatchQueryQuota(batch) | |||
| if err != nil { | |||
| common.SysError(fmt.Sprintf("[SyncManager] BatchQueryQuota failed (batch %d-%d): %v", i, end, err)) | |||
| continue | |||
| } | |||
| oldQuota := user.SyncedQuota | |||
| if err := model.UpdateSyncedQuota(user.Id, resp.Quota); err != nil { | |||
| common.SysError(fmt.Sprintf("[SyncManager] UpdateSyncedQuota failed for user %d: %v", user.Id, err)) | |||
| continue | |||
| for _, entry := range resp.Quotas { | |||
| if user, ok := userMap[entry.UserId]; ok { | |||
| oldQuota := user.SyncedQuota | |||
| if err := model.UpdateSyncedQuota(user.Id, entry.Quota); err != nil { | |||
| common.SysError(fmt.Sprintf("[SyncManager] UpdateSyncedQuota failed for user %d: %v", user.Id, err)) | |||
| continue | |||
| } | |||
| logger.LogDebug(nil, "[RegionSync] RunQuotaSync: userId=%d, syncedQuota %d -> %d", user.Id, oldQuota, entry.Quota) | |||
| syncedCount++ | |||
| } | |||
| } | |||
| logger.LogDebug(nil, "[RegionSync] RunQuotaSync: userId=%d, syncedQuota %d -> %d", user.Id, oldQuota, resp.Quota) | |||
| syncedCount++ | |||
| } | |||
| logger.LogDebug(nil, "[RegionSync] RunQuotaSync: completed, synced=%d/%d", syncedCount, len(users)) | |||
| return syncedCount | |||
| } | |||
| @@ -81,3 +81,21 @@ type SyncConfigResponse struct { | |||
| SyncIntervalSeconds int `json:"sync_interval_seconds"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| // QuotaEntry 单个用户的余额信息 | |||
| type QuotaEntry struct { | |||
| UserId int `json:"user_id"` | |||
| Quota int `json:"quota"` | |||
| } | |||
| // BatchQueryQuotaRequest 批量查询余额请求 | |||
| type BatchQueryQuotaRequest struct { | |||
| UserIds []int `json:"user_ids"` | |||
| } | |||
| // BatchQueryQuotaResponse 批量查询余额响应 | |||
| type BatchQueryQuotaResponse struct { | |||
| Success bool `json:"success"` | |||
| Quotas []QuotaEntry `json:"quotas"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| @@ -357,6 +357,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error { | |||
| func GetModelPrice(name string, printErr bool) (float64, bool) { | |||
| name = FormatMatchingModelName(name) | |||
| // 优先匹配精确模型名称 | |||
| if price, ok := modelPriceMap.Get(name); ok { | |||
| return price, true | |||
| } | |||
| if strings.HasSuffix(name, CompactModelSuffix) { | |||
| price, ok := modelPriceMap.Get(CompactWildcardModelKey) | |||
| if !ok { | |||
| @@ -21,7 +21,7 @@ var defaultFetchSetting = FetchSetting{ | |||
| DomainList: []string{}, | |||
| IpList: []string{}, | |||
| AllowedPorts: []string{"80", "443", "8080", "8443"}, | |||
| ApplyIPFilterForDomain: false, | |||
| ApplyIPFilterForDomain: true, | |||
| } | |||
| func init() { | |||
| @@ -0,0 +1,59 @@ | |||
| #!/bin/bash | |||
| # 用法: ./update-image.sh [目录1] [目录2] ... <new-tag> | |||
| # 示例: ./update-image.sh /opt/ov /opt/cn 202604071354-feat-region-sync-glm | |||
| # | |||
| # 脚本会修改指定目录下 docker-compose.yml 中 new-api 服务的 image tag。 | |||
| # 如果不传目录参数,默认修改当前目录。 | |||
| set -e | |||
| if [ $# -lt 1 ]; then | |||
| echo "错误: 请提供新的 tag" | |||
| echo "用法: $0 [目录1] [目录2] ... <new-tag>" | |||
| echo "示例: $0 /opt/ov /opt/cn 202604071354-feat-region-sync-glm" | |||
| exit 1 | |||
| fi | |||
| TAG="${@: -1}" | |||
| if [ $# -gt 1 ]; then | |||
| DIRS=("${@:1:$#-1}") | |||
| else | |||
| DIRS=(".") | |||
| fi | |||
| update_file() { | |||
| local FILE="$1" | |||
| local TAG="$2" | |||
| if [ ! -f "$FILE" ]; then | |||
| echo "跳过: $FILE 不存在" | |||
| return | |||
| fi | |||
| OLD_IMAGE=$(grep -E '^\s+image:.*new-api' "$FILE" | head -1 | sed 's/.*image: *//') | |||
| OLD_TAG="${OLD_IMAGE##*:}" | |||
| REPO="${OLD_IMAGE%:*}" | |||
| if [ -z "$OLD_TAG" ] || [ -z "$REPO" ]; then | |||
| echo "错误: 无法从 $FILE 中解析 new-api 的 image 配置" | |||
| return 1 | |||
| fi | |||
| NEW_IMAGE="${REPO}:${TAG}" | |||
| if [[ "$OSTYPE" == "darwin"* ]]; then | |||
| perl -i -pe "s|(\s+image:) *${OLD_IMAGE}|\$1 ${NEW_IMAGE}|" "$FILE" | |||
| else | |||
| sed -i "s|${OLD_IMAGE}|${NEW_IMAGE}|" "$FILE" | |||
| fi | |||
| echo "已更新 $FILE:" | |||
| echo " 旧: ${OLD_IMAGE}" | |||
| echo " 新: ${NEW_IMAGE}" | |||
| } | |||
| for DIR in "${DIRS[@]}"; do | |||
| FILE="${DIR%/}/docker-compose.yml" | |||
| update_file "$FILE" "$TAG" | |||
| done | |||
| @@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import React, { useEffect, useState } from 'react'; | |||
| import React, { useEffect, useMemo, useState } from 'react'; | |||
| import { API, showError } from '../../../helpers'; | |||
| import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; | |||
| const { Title } = Typography; | |||
| @@ -28,7 +28,7 @@ import { | |||
| import { useTranslation } from 'react-i18next'; | |||
| import MarkdownRenderer from '../markdown/MarkdownRenderer'; | |||
| // 检查是否为 URL | |||
| // Check whether content is a URL. | |||
| const isUrl = (content) => { | |||
| try { | |||
| new URL(content.trim()); | |||
| @@ -38,27 +38,23 @@ const isUrl = (content) => { | |||
| } | |||
| }; | |||
| // 检查是否为 HTML 内容 | |||
| // Check whether content contains HTML. | |||
| const isHtmlContent = (content) => { | |||
| if (!content || typeof content !== 'string') return false; | |||
| // 检查是否包含HTML标签 | |||
| const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; | |||
| return htmlTagRegex.test(content); | |||
| }; | |||
| // 安全地渲染HTML内容 | |||
| // Parse HTML content and extract inline styles. | |||
| const sanitizeHtml = (html) => { | |||
| // 创建一个临时元素来解析HTML | |||
| const tempDiv = document.createElement('div'); | |||
| tempDiv.innerHTML = html; | |||
| // 提取样式 | |||
| const styles = Array.from(tempDiv.querySelectorAll('style')) | |||
| .map((style) => style.innerHTML) | |||
| .join('\n'); | |||
| // 提取body内容,如果没有body标签则使用全部内容 | |||
| const bodyContent = tempDiv.querySelector('body'); | |||
| const content = bodyContent ? bodyContent.innerHTML : html; | |||
| @@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||
| const { t } = useTranslation(); | |||
| const [content, setContent] = useState(''); | |||
| const [loading, setLoading] = useState(true); | |||
| const [htmlStyles, setHtmlStyles] = useState(''); | |||
| const [processedHtmlContent, setProcessedHtmlContent] = useState(''); | |||
| const loadContent = async () => { | |||
| // 先从缓存中获取 | |||
| const cachedContent = localStorage.getItem(cacheKey) || ''; | |||
| if (cachedContent) { | |||
| setContent(cachedContent); | |||
| processContent(cachedContent); | |||
| setLoading(false); | |||
| } | |||
| @@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||
| const { success, message, data } = res.data; | |||
| if (success && data) { | |||
| setContent(data); | |||
| processContent(data); | |||
| localStorage.setItem(cacheKey, data); | |||
| } else { | |||
| if (!cachedContent) { | |||
| @@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||
| } | |||
| }; | |||
| const processContent = (rawContent) => { | |||
| if (isHtmlContent(rawContent)) { | |||
| const { content: htmlContent, styles } = sanitizeHtml(rawContent); | |||
| setProcessedHtmlContent(htmlContent); | |||
| setHtmlStyles(styles); | |||
| } else { | |||
| setProcessedHtmlContent(''); | |||
| setHtmlStyles(''); | |||
| const htmlPayload = useMemo(() => { | |||
| if (!isHtmlContent(content)) { | |||
| return { content: '', styles: '' }; | |||
| } | |||
| }; | |||
| return sanitizeHtml(content); | |||
| }, [content]); | |||
| useEffect(() => { | |||
| loadContent(); | |||
| @@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||
| // 处理HTML样式注入 | |||
| useEffect(() => { | |||
| const styleId = `document-renderer-styles-${cacheKey}`; | |||
| const { styles } = htmlPayload; | |||
| if (htmlStyles) { | |||
| if (styles) { | |||
| let styleEl = document.getElementById(styleId); | |||
| if (!styleEl) { | |||
| styleEl = document.createElement('style'); | |||
| @@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||
| styleEl.type = 'text/css'; | |||
| document.head.appendChild(styleEl); | |||
| } | |||
| styleEl.innerHTML = htmlStyles; | |||
| styleEl.innerHTML = styles; | |||
| } else { | |||
| const el = document.getElementById(styleId); | |||
| if (el) el.remove(); | |||
| @@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||
| const el = document.getElementById(styleId); | |||
| if (el) el.remove(); | |||
| }; | |||
| }, [htmlStyles, cacheKey]); | |||
| }, [cacheKey, htmlPayload]); | |||
| // 显示加载状态 | |||
| if (loading) { | |||
| @@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||
| // 如果是 HTML 内容,直接渲染 | |||
| if (isHtmlContent(content)) { | |||
| const { content: htmlContent, styles } = sanitizeHtml(content); | |||
| // 设置样式(如果有的话) | |||
| useEffect(() => { | |||
| if (styles && styles !== htmlStyles) { | |||
| setHtmlStyles(styles); | |||
| } | |||
| }, [content, styles, htmlStyles]); | |||
| return ( | |||
| <div className='min-h-screen bg-gray-50'> | |||
| <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'> | |||
| @@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||
| </Title> | |||
| <div | |||
| className='prose prose-lg max-w-none' | |||
| dangerouslySetInnerHTML={{ __html: htmlContent }} | |||
| dangerouslySetInnerHTML={{ __html: htmlPayload.content }} | |||
| /> | |||
| </div> | |||
| </div> | |||
| @@ -595,7 +595,7 @@ export default function SettingsChannelAffinity(props) { | |||
| include_rule_name: !!values.include_rule_name, | |||
| ...(values.skip_retry_on_failure | |||
| ? { skip_retry_on_failure: true } | |||
| : {}), | |||
| : { skip_retry_on_failure: false }), | |||
| ...(userAgentInclude.length > 0 | |||
| ? { user_agent_include: userAgentInclude } | |||
| : {}), | |||