From 3fc339e5e95fe6f04a743d5a5d69b4adecacf097 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 2 Jul 2026 00:55:50 +0800 Subject: [PATCH 1/8] v1.0.4-preview.1 --- controller/relay.go | 1 + dto/channel_settings.go | 6 + dto/openai_response.go | 2 +- model/log.go | 98 ++- model/log_test.go | 97 ++- model/main.go | 25 +- model/task.go | 1 + model/usedata.go | 107 +++- relay/channel/advancedcustom/adaptor.go | 17 +- relay/channel/advancedcustom/adaptor_test.go | 46 ++ relay/channel/claude/relay-claude.go | 7 +- relay/channel/claude/relay_claude_test.go | 80 ++- relay/channel/gemini/adaptor.go | 19 +- relay/channel/gemini/adaptor_responses.go | 97 +++ .../channel/gemini/adaptor_responses_test.go | 99 +++ .../channel/gemini/relay_gemini_usage_test.go | 74 +++ relay/channel/gemini/relay_responses.go | 175 ++++++ relay/channel/openai/responses_via_chat.go | 135 ++++ relay/channel/task/doubao/adaptor.go | 94 ++- .../doubao/adaptor_media_protocol_test.go | 187 ++++++ relay/channel/task/doubao/constants.go | 53 +- service/convert.go | 46 +- service/convert_test.go | 86 +++ service/openai_chat_responses_compat.go | 16 + .../chat_to_responses_response.go | 595 ++++++++++++++++++ service/openaicompat/responses_compat_test.go | 323 ++++++++++ .../openaicompat/responses_request_to_chat.go | 480 ++++++++++++++ service/openaicompat/responses_to_chat.go | 137 +++- service/task_billing.go | 2 + service/task_billing_test.go | 53 ++ .../features/channels/lib/advanced-custom.ts | 10 + web/default/src/features/channels/types.ts | 1 + .../components/common-logs-filter-bar.tsx | 2 +- .../src/features/usage-logs/constants.ts | 25 +- .../src/features/usage-logs/lib/utils.test.ts | 85 +++ .../src/features/usage-logs/lib/utils.ts | 39 +- web/default/src/features/usage-logs/types.ts | 2 + web/default/src/i18n/locales/en.json | 6 +- web/default/src/i18n/locales/fr.json | 6 +- web/default/src/i18n/locales/ja.json | 6 +- web/default/src/i18n/locales/ru.json | 6 +- web/default/src/i18n/locales/vi.json | 6 +- web/default/src/i18n/locales/zh.json | 6 +- web/default/src/i18n/static-keys.ts | 2 + .../_authenticated/usage-logs/$section.tsx | 4 +- 45 files changed, 3182 insertions(+), 182 deletions(-) create mode 100644 relay/channel/gemini/adaptor_responses.go create mode 100644 relay/channel/gemini/adaptor_responses_test.go create mode 100644 relay/channel/gemini/relay_responses.go create mode 100644 relay/channel/openai/responses_via_chat.go create mode 100644 service/convert_test.go create mode 100644 service/openaicompat/chat_to_responses_response.go create mode 100644 service/openaicompat/responses_compat_test.go create mode 100644 service/openaicompat/responses_request_to_chat.go diff --git a/controller/relay.go b/controller/relay.go index 409e5b5..58a0a1a 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -587,6 +587,7 @@ func RelayTask(c *gin.Context) { task.PrivateData.BillingSource = relayInfo.BillingSource task.PrivateData.SubscriptionId = relayInfo.SubscriptionId task.PrivateData.TokenId = relayInfo.TokenId + task.PrivateData.NodeName = common.NodeName task.PrivateData.BillingContext = &model.TaskBillingContext{ ModelPrice: relayInfo.PriceData.ModelPrice, GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 024a3b8..e549d7a 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -77,6 +77,7 @@ const ( AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions" AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages" AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses" + AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions = "openai_responses_to_openai_chat_completions" AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions" AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content" ) @@ -161,6 +162,7 @@ func IsAdvancedCustomConverterAllowed(converter string) bool { AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions, AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages, AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses, + AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions, AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions, AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent: return true @@ -254,6 +256,10 @@ func validateAdvancedCustomConverterPath(index int, incomingPath string, convert if incomingPath == "/v1/chat/completions" { return nil } + case AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + if incomingPath == "/v1/responses" { + return nil + } case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions: if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") { return nil diff --git a/dto/openai_response.go b/dto/openai_response.go index b424a8d..00069d1 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -334,7 +334,7 @@ func (o *OpenAIResponsesResponse) GetSize() string { } type IncompleteDetails struct { - Reasoning string `json:"reasoning"` + Reason string `json:"reason"` } type ResponsesOutput struct { diff --git a/model/log.go b/model/log.go index 3db1ee1..ea43ebb 100644 --- a/model/log.go +++ b/model/log.go @@ -54,6 +54,8 @@ type Log struct { UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` Other string `json:"other"` IsRetry bool `json:"is_retry" gorm:"default:false;index"` + IsErrorRetry bool `json:"is_error_retry" gorm:"default:false;index"` + IsEmptyRetry bool `json:"is_empty_retry" gorm:"default:false;index"` LogId int `json:"log_id,omitempty" gorm:"-"` } @@ -71,7 +73,10 @@ func (log *Log) syncRetryMarker() { if log == nil { return } - log.IsRetry = logOtherHasRetryMarker(log.Other) + errorRetry, emptyRetry := logOtherRetryMarkers(log.Other) + log.IsErrorRetry = errorRetry + log.IsEmptyRetry = emptyRetry + log.IsRetry = errorRetry || emptyRetry } // don't use iota, avoid change log type value @@ -86,7 +91,11 @@ const ( LogTypeLogin = 7 ) -const LogFilterRetry = "retry" +const ( + LogFilterRetry = "retry" + LogFilterErrorRetry = "error_retry" + LogFilterEmptyRetry = "empty_retry" +) func applyLogTypeFilter(tx *gorm.DB, logType int) *gorm.DB { if logType == LogTypeUnknown { @@ -104,6 +113,20 @@ func applyRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { return tx.Where("logs.is_retry = ?", true), nil } +func applyErrorRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { + if err := ensureLogRetryMarkerBackfillCompletedForRead(); err != nil { + return nil, err + } + return tx.Where("logs.is_error_retry = ?", true), nil +} + +func applyEmptyRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { + if err := ensureLogRetryMarkerBackfillCompletedForRead(); err != nil { + return nil, err + } + return tx.Where("logs.is_empty_retry = ?", true), nil +} + func ensureLogRetryMarkerBackfillCompletedForReadDefault() error { completed, err := isLogRetryMarkerBackfillCompleted() if err != nil { @@ -122,11 +145,24 @@ func applyLogFilter(tx *gorm.DB, filter string) (*gorm.DB, error) { switch filter { case LogFilterRetry: return applyRetryLogFilter(tx) + case LogFilterErrorRetry: + return applyErrorRetryLogFilter(tx) + case LogFilterEmptyRetry: + return applyEmptyRetryLogFilter(tx) default: return tx, nil } } +func isRetryLogFilter(filter string) bool { + switch filter { + case LogFilterRetry, LogFilterErrorRetry, LogFilterEmptyRetry: + return true + default: + return false + } +} + func userVisibleAuditInfo(adminInfoValue interface{}) map[string]interface{} { if !common.LogRequestContentEnabled && !common.LogResponseContentEnabled { return nil @@ -198,20 +234,25 @@ func stripLogsAuditContent(logs []*Log) { } func logOtherHasRetryMarker(other string) bool { + errorRetry, emptyRetry := logOtherRetryMarkers(other) + return errorRetry || emptyRetry +} + +func logOtherRetryMarkers(other string) (errorRetry bool, emptyRetry bool) { if strings.TrimSpace(other) == "" { - return false + return false, false } otherMap, err := common.StrToMap(other) if err != nil || otherMap == nil { - return false + return false, false } - if retryLog, ok := otherMap["retry_log"].(bool); ok && retryLog { - return true + if marker, ok := otherMap["empty_retry"].(bool); ok && marker { + return false, true } - if emptyRetry, ok := otherMap["empty_retry"].(bool); ok && emptyRetry { - return true + if retryLog, ok := otherMap["retry_log"].(bool); ok && retryLog { + errorRetry = true } - return false + return errorRetry, emptyRetry } func formatUserLog(log *Log) { @@ -491,8 +532,20 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) logger.LogError(c, "failed to record log: "+err.Error()) } if common.DataExportEnabled { + createdAt := common.GetTimestamp() gopool.Go(func() { - LogQuotaData(userId, username, params.ModelName, params.Quota, common.GetTimestamp(), params.PromptTokens+params.CompletionTokens) + LogQuotaData(QuotaDataLogParams{ + UserID: userId, + Username: username, + ModelName: params.ModelName, + Quota: params.Quota, + CreatedAt: createdAt, + TokenUsed: params.PromptTokens + params.CompletionTokens, + UseGroup: params.Group, + TokenID: params.TokenId, + ChannelID: params.ChannelId, + NodeName: common.NodeName, + }) }) } } @@ -507,6 +560,7 @@ type RecordTaskBillingLogParams struct { TokenId int Group string Other map[string]interface{} + NodeName string // 任务发起节点;为空时回退当前节点 } func RecordTaskBillingLog(params RecordTaskBillingLogParams) { @@ -520,10 +574,11 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { tokenName = token.Name } } + createdAt := common.GetTimestamp() log := &Log{ UserId: params.UserId, Username: username, - CreatedAt: common.GetTimestamp(), + CreatedAt: createdAt, Type: params.LogType, Content: params.Content, TokenName: tokenName, @@ -538,6 +593,25 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { if err != nil { common.SysLog("failed to record task billing log: " + err.Error()) } + if params.LogType == LogTypeConsume && common.DataExportEnabled { + nodeName := params.NodeName + if nodeName == "" { + nodeName = common.NodeName + } + gopool.Go(func() { + LogQuotaData(QuotaDataLogParams{ + UserID: params.UserId, + Username: username, + ModelName: params.ModelName, + Quota: params.Quota, + CreatedAt: createdAt, + UseGroup: params.Group, + TokenID: params.TokenId, + ChannelID: params.ChannelId, + NodeName: nodeName, + }) + }) + } } func GetAllLogs(logType int, logFilter string, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) { @@ -758,7 +832,7 @@ func SumUsedQuota(logType int, logFilter string, startTimestamp int64, endTimest if logType != LogTypeUnknown { tx = tx.Where("logs.type = ?", logType) rpmTpmQuery = rpmTpmQuery.Where("logs.type = ?", logType) - } else if logFilter != LogFilterRetry { + } else if !isRetryLogFilter(logFilter) { tx = tx.Where("logs.type = ?", LogTypeConsume) rpmTpmQuery = rpmTpmQuery.Where("logs.type = ?", LogTypeConsume) } diff --git a/model/log_test.go b/model/log_test.go index 114df37..9554906 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -34,9 +34,9 @@ func TestGetAllLogsRetryFilter(t *testing.T) { got, total, err := GetAllLogs(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") require.NoError(t, err) - require.EqualValues(t, 4, total) - require.Len(t, got, 4) - require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id, logs[4].Id, logs[5].Id}, []int{got[0].Id, got[1].Id, got[2].Id, got[3].Id}) + require.EqualValues(t, 5, total) + require.Len(t, got, 5) + require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id, logs[4].Id, logs[5].Id, logs[6].Id}, []int{got[0].Id, got[1].Id, got[2].Id, got[3].Id, got[4].Id}) } func TestGetUserLogsRetryFilter(t *testing.T) { @@ -49,10 +49,10 @@ func TestGetUserLogsRetryFilter(t *testing.T) { got, total, err := GetUserLogs(1, LogTypeUnknown, LogFilterRetry, 0, 0, "", "", 0, 10, "", "", "") require.NoError(t, err) - require.EqualValues(t, 3, total) - require.Len(t, got, 3) - gotLogIds := []int{got[0].LogId, got[1].LogId, got[2].LogId} - require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id, logs[5].Id}, gotLogIds) + require.EqualValues(t, 4, total) + require.Len(t, got, 4) + gotLogIds := []int{got[0].LogId, got[1].LogId, got[2].LogId, got[3].LogId} + require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id, logs[5].Id, logs[6].Id}, gotLogIds) require.NotContains(t, gotLogIds, logs[4].Id) for _, log := range got { require.Equal(t, 1, log.UserId) @@ -69,9 +69,51 @@ func TestSumUsedQuotaRetryFilter(t *testing.T) { stat, err := SumUsedQuota(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, "") require.NoError(t, err) - require.Equal(t, 1200, stat.Quota) - require.Equal(t, 4, stat.Rpm) - require.Equal(t, 71, stat.Tpm) + require.Equal(t, 1550, stat.Quota) + require.Equal(t, 5, stat.Rpm) + require.Equal(t, 96, stat.Tpm) +} + +func TestGetAllLogsRetrySubtypeFilters(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + logs := createRetryFilterLogs(t) + + errorLogs, total, err := GetAllLogs(LogTypeUnknown, LogFilterErrorRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") + require.NoError(t, err) + require.EqualValues(t, 3, total) + require.Len(t, errorLogs, 3) + require.ElementsMatch(t, []int{logs[0].Id, logs[4].Id, logs[5].Id}, []int{errorLogs[0].Id, errorLogs[1].Id, errorLogs[2].Id}) + + emptyLogs, total, err := GetAllLogs(LogTypeUnknown, LogFilterEmptyRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") + require.NoError(t, err) + require.EqualValues(t, 2, total) + require.Len(t, emptyLogs, 2) + require.ElementsMatch(t, []int{logs[1].Id, logs[6].Id}, []int{emptyLogs[0].Id, emptyLogs[1].Id}) +} + +func TestSumUsedQuotaRetrySubtypeFilters(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + createRetryFilterLogs(t) + + errorStat, err := SumUsedQuota(LogTypeUnknown, LogFilterErrorRetry, 0, 0, "", "", "", 0, "") + require.NoError(t, err) + require.Equal(t, 1000, errorStat.Quota) + require.Equal(t, 3, errorStat.Rpm) + require.Equal(t, 56, errorStat.Tpm) + + emptyStat, err := SumUsedQuota(LogTypeUnknown, LogFilterEmptyRetry, 0, 0, "", "", "", 0, "") + require.NoError(t, err) + require.Equal(t, 550, emptyStat.Quota) + require.Equal(t, 2, emptyStat.Rpm) + require.Equal(t, 40, emptyStat.Tpm) } func TestSumUsedQuotaAppliesExplicitLogType(t *testing.T) { @@ -166,9 +208,7 @@ func TestRetryFilterBackfillsLegacyMarkersBeforeCompletion(t *testing.T) { PromptTokens: 7, CompletionTokens: 11, Other: common.MapToJsonStr(map[string]interface{}{ - "admin_info": map[string]interface{}{ - "retry_log": true, - }, + "empty_retry": true, }), }, } @@ -177,15 +217,19 @@ func TestRetryFilterBackfillsLegacyMarkersBeforeCompletion(t *testing.T) { got, total, err := GetAllLogs(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") require.NoError(t, err) - require.EqualValues(t, 1, total) - require.Len(t, got, 1) - require.Equal(t, logs[0].Id, got[0].Id) + require.EqualValues(t, 2, total) + require.Len(t, got, 2) + require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id}, []int{got[0].Id, got[1].Id}) var reloaded []Log require.NoError(t, LOG_DB.Order("id asc").Find(&reloaded).Error) require.Len(t, reloaded, 2) require.True(t, reloaded[0].IsRetry) - require.False(t, reloaded[1].IsRetry) + require.True(t, reloaded[0].IsErrorRetry) + require.False(t, reloaded[0].IsEmptyRetry) + require.True(t, reloaded[1].IsRetry) + require.False(t, reloaded[1].IsErrorRetry) + require.True(t, reloaded[1].IsEmptyRetry) } func TestRetryFilterUsesIsRetryAfterBackfillCompletion(t *testing.T) { @@ -281,7 +325,11 @@ func TestBackfillLogRetryMarkerUsesTopLevelMarkersOnly(t *testing.T) { require.NoError(t, LOG_DB.Order("id asc").Find(&reloaded).Error) require.Len(t, reloaded, 2) require.True(t, reloaded[0].IsRetry) + require.True(t, reloaded[0].IsErrorRetry) + require.False(t, reloaded[0].IsEmptyRetry) require.False(t, reloaded[1].IsRetry) + require.False(t, reloaded[1].IsErrorRetry) + require.False(t, reloaded[1].IsEmptyRetry) } func TestBackfillLogRetryMarkerSkipsAfterCompletionMarker(t *testing.T) { @@ -606,6 +654,21 @@ func createRetryFilterLogs(t *testing.T) []Log { }, }), }, + { + UserId: 1, + CreatedAt: time.Now().Unix() - 60, + Type: LogTypeConsume, + Quota: 350, + PromptTokens: 12, + CompletionTokens: 13, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + "empty_retry": true, + "admin_info": map[string]interface{}{ + "use_channel": []string{"9", "10"}, + }, + }), + }, } require.NoError(t, LOG_DB.Create(&logs).Error) diff --git a/model/main.go b/model/main.go index b432ff6..b986b9b 100644 --- a/model/main.go +++ b/model/main.go @@ -27,7 +27,7 @@ var commonFalseVal string var logKeyCol string var logGroupCol string -const logRetryMarkerBackfillOptionKey = "LogRetryMarkerBackfillCompleted" +const logRetryMarkerBackfillOptionKey = "LogRetryMarkerBackfillCompletedV2" var logRetryMarkerBackfillMu sync.Mutex @@ -446,8 +446,8 @@ func backfillLogRetryMarker() error { for { var logs []Log if err := LOG_DB. - Select("id", "other", "is_retry"). - Where("id > ? AND is_retry = ?", lastId, false). + Select("id", "other", "is_retry", "is_error_retry", "is_empty_retry"). + Where("id > ? AND (is_retry = ? OR is_error_retry = ? OR is_empty_retry = ?)", lastId, false, false, false). Order("id asc"). Limit(batchSize). Find(&logs).Error; err != nil { @@ -457,20 +457,21 @@ func backfillLogRetryMarker() error { return markLogRetryMarkerBackfillCompleted() } - retryIds := make([]int, 0, len(logs)) for _, log := range logs { lastId = log.Id - if log.IsRetry { + errorRetry, emptyRetry := logOtherRetryMarkers(log.Other) + if log.IsRetry == (errorRetry || emptyRetry) && + log.IsErrorRetry == errorRetry && + log.IsEmptyRetry == emptyRetry { continue } - if logOtherHasRetryMarker(log.Other) { - retryIds = append(retryIds, log.Id) - } - } - if len(retryIds) > 0 { if err := LOG_DB.Model(&Log{}). - Where("id IN ?", retryIds). - Update("is_retry", true).Error; err != nil { + Where("id = ?", log.Id). + Updates(map[string]interface{}{ + "is_retry": errorRetry || emptyRetry, + "is_error_retry": errorRetry, + "is_empty_retry": emptyRetry, + }).Error; err != nil { return err } } diff --git a/model/task.go b/model/task.go index 7eec69c..516b102 100644 --- a/model/task.go +++ b/model/task.go @@ -105,6 +105,7 @@ type TaskPrivateData struct { BillingSource string `json:"billing_source,omitempty"` // "wallet" 或 "subscription" SubscriptionId int `json:"subscription_id,omitempty"` // 订阅 ID,用于订阅退款 TokenId int `json:"token_id,omitempty"` // 令牌 ID,用于令牌额度退款 + NodeName string `json:"node_name,omitempty"` // 发起任务的节点名,轮询结算阶段据此归属日志而非最后查询节点 BillingContext *TaskBillingContext `json:"billing_context,omitempty"` // 计费参数快照(用于轮询阶段重新计算) } diff --git a/model/usedata.go b/model/usedata.go index a47fbeb..7b534aa 100644 --- a/model/usedata.go +++ b/model/usedata.go @@ -16,11 +16,28 @@ type QuotaData struct { Username string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"` ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"` CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"` + UseGroup string `json:"use_group" gorm:"index;size:64;default:''"` + TokenID int `json:"token_id" gorm:"index;default:0"` + ChannelID int `json:"channel_id" gorm:"index;default:0"` + NodeName string `json:"node_name" gorm:"index;size:64;default:''"` TokenUsed int `json:"token_used" gorm:"default:0"` Count int `json:"count" gorm:"default:0"` Quota int `json:"quota" gorm:"default:0"` } +type QuotaDataLogParams struct { + UserID int + Username string + ModelName string + Quota int + CreatedAt int64 + TokenUsed int + UseGroup string + TokenID int + ChannelID int + NodeName string +} + func UpdateQuotaData() { for { if common.DataExportEnabled { @@ -34,34 +51,50 @@ func UpdateQuotaData() { var CacheQuotaData = make(map[string]*QuotaData) var CacheQuotaDataLock = sync.Mutex{} -func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { - key := fmt.Sprintf("%d-%s-%s-%d", userId, username, modelName, createdAt) - quotaData, ok := CacheQuotaData[key] +func logQuotaDataCache(quotaData *QuotaData) { + key := fmt.Sprintf("%d\x00%s\x00%s\x00%d\x00%s\x00%d\x00%d\x00%s", + quotaData.UserID, + quotaData.Username, + quotaData.ModelName, + quotaData.CreatedAt, + quotaData.UseGroup, + quotaData.TokenID, + quotaData.ChannelID, + quotaData.NodeName, + ) + count := quotaData.Count + quota := quotaData.Quota + tokenUsed := quotaData.TokenUsed + cachedQuotaData, ok := CacheQuotaData[key] if ok { - quotaData.Count += 1 - quotaData.Quota += quota - quotaData.TokenUsed += tokenUsed - } else { - quotaData = &QuotaData{ - UserID: userId, - Username: username, - ModelName: modelName, - CreatedAt: createdAt, - Count: 1, - Quota: quota, - TokenUsed: tokenUsed, - } + cachedQuotaData.Count += count + cachedQuotaData.Quota += quota + cachedQuotaData.TokenUsed += tokenUsed + quotaData = cachedQuotaData } CacheQuotaData[key] = quotaData } -func LogQuotaData(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { +func LogQuotaData(params QuotaDataLogParams) { // 只精确到小时 - createdAt = createdAt - (createdAt % 3600) + createdAt := params.CreatedAt - (params.CreatedAt % 3600) + quotaData := &QuotaData{ + UserID: params.UserID, + Username: params.Username, + ModelName: params.ModelName, + CreatedAt: createdAt, + UseGroup: params.UseGroup, + TokenID: params.TokenID, + ChannelID: params.ChannelID, + NodeName: params.NodeName, + Count: 1, + Quota: params.Quota, + TokenUsed: params.TokenUsed, + } CacheQuotaDataLock.Lock() defer CacheQuotaDataLock.Unlock() - logQuotaDataCache(userId, username, modelName, quota, createdAt, tokenUsed) + logQuotaDataCache(quotaData) } func SaveQuotaDataCache() { @@ -74,13 +107,15 @@ func SaveQuotaDataCache() { // 3. 如果没有数据,就插入数据 for _, quotaData := range CacheQuotaData { quotaDataDB := &QuotaData{} - DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", - quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt).First(quotaDataDB) + DB.Table("quota_data"). + Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). + First(quotaDataDB) if quotaDataDB.Id > 0 { //quotaDataDB.Count += quotaData.Count //quotaDataDB.Quota += quotaData.Quota //DB.Table("quota_data").Save(quotaDataDB) - increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt, quotaData.TokenUsed) + increaseQuotaData(quotaData) } else { DB.Table("quota_data").Create(quotaData) } @@ -89,13 +124,15 @@ func SaveQuotaDataCache() { common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size)) } -func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64, tokenUsed int) { - err := DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", - userId, username, modelName, createdAt).Updates(map[string]interface{}{ - "count": gorm.Expr("count + ?", count), - "quota": gorm.Expr("quota + ?", quota), - "token_used": gorm.Expr("token_used + ?", tokenUsed), - }).Error +func increaseQuotaData(quotaData *QuotaData) { + err := DB.Table("quota_data"). + Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). + Updates(map[string]interface{}{ + "count": gorm.Expr("count + ?", quotaData.Count), + "quota": gorm.Expr("quota + ?", quotaData.Quota), + "token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed), + }).Error if err != nil { common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err)) } @@ -104,14 +141,22 @@ func increaseQuotaData(userId int, username string, modelName string, count int, func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData // 从quota_data表中查询数据 - err = DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).Find("aDatas).Error + err = DB.Table("quota_data"). + Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime). + Group("user_id, username, model_name, created_at"). + Find("aDatas).Error return quotaDatas, err } func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData // 从quota_data表中查询数据 - err = DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).Find("aDatas).Error + err = DB.Table("quota_data"). + Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime). + Group("user_id, username, model_name, created_at"). + Find("aDatas).Error return quotaDatas, err } diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go index 1198137..9ac043c 100644 --- a/relay/channel/advancedcustom/adaptor.go +++ b/relay/channel/advancedcustom/adaptor.go @@ -103,10 +103,18 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo if err != nil { return nil, err } - if converter != dto.AdvancedCustomConverterNone { + switch converter { + case dto.AdvancedCustomConverterNone: + return a.convertOpenAICompatibleResponsesRequest(c, info, request) + case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + chatReq, err := service.ResponsesRequestToChatCompletionsRequest(&request) + if err != nil { + return nil, err + } + return a.convertOpenAICompatibleRequest(c, info, chatReq) + default: return nil, fmt.Errorf("converter %q does not support OpenAI Responses requests", converter) } - return a.convertOpenAICompatibleResponsesRequest(c, info, request) } func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { @@ -220,6 +228,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom return openai.OaiResponsesToChatStreamHandler(c, info, resp) } return openai.OaiResponsesToChatHandler(c, info, resp) + case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + if info.IsStream { + return openai.OaiChatToResponsesStreamHandler(c, info, resp) + } + return openai.OaiChatToResponsesHandler(c, info, resp) default: return nil, types.NewOpenAIError(fmt.Errorf("unsupported advanced custom converter: %s", converter), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go index 4f609e9..06251bb 100644 --- a/relay/channel/advancedcustom/adaptor_test.go +++ b/relay/channel/advancedcustom/adaptor_test.go @@ -6,6 +6,7 @@ import ( "net/url" "testing" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" @@ -368,6 +369,44 @@ func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) { } } +func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/responses", + UpstreamPath: "/v1/chat/completions", + Converter: dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions, + }, + }, + }) + info.RelayMode = relayconstant.RelayModeResponses + info.RequestURLPath = "/v1/responses" + c := advancedCustomGinContext("/v1/responses") + + converted, err := adaptor.ConvertOpenAIResponsesRequest(c, info, dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Instructions: mustAdvancedCustomRawMessage(t, "system rules"), + Input: mustAdvancedCustomRawMessage(t, "hello"), + }) + require.NoError(t, err) + + chatReq, ok := converted.(*dto.GeneralOpenAIRequest) + require.True(t, ok) + assert.Equal(t, "gpt-test", chatReq.Model) + require.Len(t, chatReq.Messages, 2) + assert.Equal(t, "system", chatReq.Messages[0].Role) + assert.Equal(t, "system rules", chatReq.Messages[0].StringContent()) + assert.Equal(t, "user", chatReq.Messages[1].Role) + assert.Equal(t, "hello", chatReq.Messages[1].StringContent()) + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "/v1/chat/completions", parsedURL.Path) +} + func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo { return &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, @@ -391,3 +430,10 @@ func advancedCustomGinContext(path string) *gin.Context { c.Request.Header.Set("Content-Type", "application/json") return c } + +func mustAdvancedCustomRawMessage(t *testing.T, value any) []byte { + t.Helper() + raw, err := common.Marshal(value) + require.NoError(t, err) + return raw +} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 22a4ae8..1a544fc 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -464,9 +464,10 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe if message.ToolCalls != nil { for _, toolCall := range message.ParseToolCalls() { inputObj := make(map[string]any) - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &inputObj); err != nil { - common.SysLog("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments)) - continue + if args := toolCall.Function.Arguments; args != "" { + if err := common.Unmarshal([]byte(args), &inputObj); err != nil { + return nil, fmt.Errorf("tool call function arguments is not valid JSON object: %w", err) + } } claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{ Type: "tool_use", diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go index 761fcfd..c4a15c8 100644 --- a/relay/channel/claude/relay_claude_test.go +++ b/relay/channel/claude/relay_claude_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" "github.com/stretchr/testify/require" ) @@ -304,9 +305,11 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request) require.NoError(t, err) - require.Len(t, claudeRequest.Messages, 1) + require.NotEmpty(t, claudeRequest.Messages) - content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage) + claudeMessage := claudeRequest.Messages[len(claudeRequest.Messages)-1] + require.Equal(t, "user", claudeMessage.Role) + content, ok := claudeMessage.Content.([]dto.ClaudeMediaMessage) require.True(t, ok) require.Len(t, content, 1) require.Equal(t, "text", content[0].Type) @@ -314,6 +317,79 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) require.Equal(t, "see attachment", *content[0].Text) } +func TestRequestOpenAI2ClaudeMessage_PreservesToolUseWithEmptyArguments(t *testing.T) { + rawToolCalls, err := common.Marshal([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "lookup", + Arguments: "", + }, + }, + }) + require.NoError(t, err) + + request := dto.GeneralOpenAIRequest{ + Model: "claude-3-5-sonnet", + Messages: []dto.Message{ + { + Role: "assistant", + ToolCalls: rawToolCalls, + }, + }, + } + + claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request) + require.NoError(t, err) + require.NotEmpty(t, claudeRequest.Messages) + + assistantMessage := claudeRequest.Messages[len(claudeRequest.Messages)-1] + require.Equal(t, "assistant", assistantMessage.Role) + content, ok := assistantMessage.Content.([]dto.ClaudeMediaMessage) + require.True(t, ok) + var toolUse *dto.ClaudeMediaMessage + for i := range content { + if content[i].Type == "tool_use" { + toolUse = &content[i] + break + } + } + require.NotNil(t, toolUse) + require.Equal(t, "call_1", toolUse.Id) + require.Equal(t, "lookup", toolUse.Name) + require.Empty(t, toolUse.Input) +} + +func TestRequestOpenAI2ClaudeMessage_ReturnsErrorForMalformedToolArguments(t *testing.T) { + rawToolCalls, err := common.Marshal([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "lookup", + Arguments: "{", + }, + }, + }) + require.NoError(t, err) + + request := dto.GeneralOpenAIRequest{ + Model: "claude-3-5-sonnet", + Messages: []dto.Message{ + { + Role: "assistant", + ToolCalls: rawToolCalls, + }, + }, + } + + claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request) + require.Error(t, err) + require.Nil(t, claudeRequest) + require.Contains(t, err.Error(), "tool call function arguments is not valid JSON object") +} + func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) { request := dto.GeneralOpenAIRequest{ Model: "claude-opus-4-8-high", diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index e59df2c..6f758cb 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -12,6 +12,7 @@ import ( "github.com/MAX-API-Next/MAX-API/relay/channel/openai" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/setting/model_setting" "github.com/MAX-API-Next/MAX-API/setting/reasoning" "github.com/MAX-API-Next/MAX-API/types" @@ -238,8 +239,15 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - // TODO implement me - return nil, errors.New("not implemented") + request, err := preprocessGeminiOpenAIResponsesRequest(request) + if err != nil { + return nil, err + } + chatRequest, err := service.ResponsesRequestToChatCompletionsRequest(&request) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, chatRequest) } func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { @@ -247,6 +255,13 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.MaxAPIError) { + if info.RelayMode == constant.RelayModeResponses { + if info.IsStream { + return GeminiResponsesStreamHandler(c, info, resp) + } + return GeminiResponsesHandler(c, info, resp) + } + if info.RelayMode == constant.RelayModeGemini { if strings.Contains(info.RequestURLPath, ":embedContent") || strings.Contains(info.RequestURLPath, ":batchEmbedContents") { diff --git a/relay/channel/gemini/adaptor_responses.go b/relay/channel/gemini/adaptor_responses.go new file mode 100644 index 0000000..bd41079 --- /dev/null +++ b/relay/channel/gemini/adaptor_responses.go @@ -0,0 +1,97 @@ +package gemini + +import ( + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" +) + +const ( + geminiResponsesInputTypeCustomToolCall = "custom_tool_call" + geminiResponsesInputTypeCustomToolCallOutput = "custom_tool_call_output" + geminiResponsesInputTypeFunctionCallOutput = "function_call_output" +) + +func preprocessGeminiOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) { + tools, err := filterGeminiResponsesTools(request.Tools) + if err != nil { + return request, err + } + request.Tools = tools + + input, err := filterGeminiResponsesInput(request.Input) + if err != nil { + return request, err + } + request.Input = input + + return request, nil +} + +func filterGeminiResponsesTools(raw []byte) ([]byte, error) { + if !geminiRawJSONPresent(raw) || common.GetJsonType(raw) != "array" { + return raw, nil + } + + var tools []map[string]any + if err := common.Unmarshal(raw, &tools); err != nil { + return nil, err + } + + filtered := make([]map[string]any, 0, len(tools)) + for _, tool := range tools { + if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" { + continue + } + filtered = append(filtered, tool) + } + if len(filtered) == 0 { + return nil, nil + } + return common.Marshal(filtered) +} + +func filterGeminiResponsesInput(raw []byte) ([]byte, error) { + if !geminiRawJSONPresent(raw) || common.GetJsonType(raw) != "array" { + return raw, nil + } + + var items []map[string]any + if err := common.Unmarshal(raw, &items); err != nil { + return nil, err + } + + skippedCustomCallIDs := make(map[string]struct{}) + for _, item := range items { + if strings.TrimSpace(common.Interface2String(item["type"])) != geminiResponsesInputTypeCustomToolCall { + continue + } + if callID := strings.TrimSpace(common.Interface2String(item["call_id"])); callID != "" { + skippedCustomCallIDs[callID] = struct{}{} + } + } + + filtered := make([]map[string]any, 0, len(items)) + for _, item := range items { + itemType := strings.TrimSpace(common.Interface2String(item["type"])) + switch itemType { + case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput: + continue + case geminiResponsesInputTypeFunctionCallOutput: + if _, ok := skippedCustomCallIDs[strings.TrimSpace(common.Interface2String(item["call_id"]))]; ok { + continue + } + } + filtered = append(filtered, item) + } + + return common.Marshal(filtered) +} + +func geminiRawJSONPresent(raw []byte) bool { + if len(raw) == 0 { + return false + } + return common.GetJsonType(raw) != "null" +} diff --git a/relay/channel/gemini/adaptor_responses_test.go b/relay/channel/gemini/adaptor_responses_test.go new file mode 100644 index 0000000..c533f1c --- /dev/null +++ b/relay/channel/gemini/adaptor_responses_test.go @@ -0,0 +1,99 @@ +package gemini + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertOpenAIResponsesRequestToGeminiInstructionsAndInput(t *testing.T) { + got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Instructions: mustGeminiRawMessage(t, "system rules"), + Input: mustGeminiRawMessage(t, "hello"), + }) + + require.NotNil(t, got.SystemInstructions) + require.Len(t, got.SystemInstructions.Parts, 1) + assert.Equal(t, "system rules", got.SystemInstructions.Parts[0].Text) + require.Len(t, got.Contents, 1) + assert.Equal(t, "user", got.Contents[0].Role) + require.Len(t, got.Contents[0].Parts, 1) + assert.Equal(t, "hello", got.Contents[0].Parts[0].Text) +} + +func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) { + got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Input: mustGeminiRawMessage(t, []map[string]any{ + { + "role": "assistant", + "content": []map[string]any{ + {"type": "output_text", "text": "before custom"}, + }, + }, + { + "type": "custom_tool_call", + "call_id": "call_custom", + "name": "apply_patch", + "input": "patch body", + }, + { + "type": "custom_tool_call_output", + "call_id": "call_custom", + "output": "ok", + }, + { + "type": "function_call_output", + "call_id": "call_custom", + "output": "legacy custom output", + }, + { + "role": "user", + "content": "next turn", + }, + }), + Tools: mustGeminiRawMessage(t, []map[string]any{ + {"type": "custom", "name": "apply_patch"}, + {"type": "unknown", "name": "unknown"}, + }), + }) + + assert.Empty(t, got.GetTools()) + require.Len(t, got.Contents, 2) + assert.Equal(t, "model", got.Contents[0].Role) + require.Len(t, got.Contents[0].Parts, 1) + assert.Equal(t, "before custom", got.Contents[0].Parts[0].Text) + assert.Nil(t, got.Contents[0].Parts[0].FunctionCall) + + assert.Equal(t, "user", got.Contents[1].Role) + require.Len(t, got.Contents[1].Parts, 1) + assert.Equal(t, "next turn", got.Contents[1].Parts[0].Text) + assert.Nil(t, got.Contents[1].Parts[0].FunctionResponse) +} + +func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) *dto.GeminiChatRequest { + t.Helper() + info := &relaycommon.RelayInfo{ + OriginModelName: req.Model, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: req.Model, + }, + } + got, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, req) + require.NoError(t, err) + geminiReq, ok := got.(*dto.GeminiChatRequest) + require.True(t, ok) + return geminiReq +} + +func mustGeminiRawMessage(t *testing.T, value any) []byte { + t.Helper() + raw, err := common.Marshal(value) + require.NoError(t, err) + return raw +} diff --git a/relay/channel/gemini/relay_gemini_usage_test.go b/relay/channel/gemini/relay_gemini_usage_test.go index 2c95a3d..91b2f3b 100644 --- a/relay/channel/gemini/relay_gemini_usage_test.go +++ b/relay/channel/gemini/relay_gemini_usage_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/MAX-API-Next/MAX-API/common" @@ -269,6 +270,63 @@ func TestGeminiStreamHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *t require.Equal(t, 110, usage.TotalTokens) } +func TestGeminiResponsesStreamHandlerEstimatesUsageAndAuditWhenUpstreamUsageMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + originalLogResponseContentEnabled := common.LogResponseContentEnabled + common.LogResponseContentEnabled = true + t.Cleanup(func() { + common.LogResponseContentEnabled = originalLogResponseContentEnabled + }) + + info := &relaycommon.RelayInfo{ + OriginModelName: "gemini-3-flash-preview", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gemini-3-flash-preview", + }, + } + info.SetEstimatePromptTokens(20) + + chunk := dto.GeminiChatResponse{ + Candidates: []dto.GeminiChatCandidate{ + { + Content: dto.GeminiChatContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "partial"}, + }, + }, + }, + }, + } + chunkData, err := common.Marshal(chunk) + require.NoError(t, err) + + streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n") + resp := &http.Response{ + Body: io.NopCloser(bytes.NewReader(streamBody)), + } + + usage, maxAPIError := GeminiResponsesStreamHandler(c, info, resp) + require.Nil(t, maxAPIError) + require.NotNil(t, usage) + require.Equal(t, 20, usage.PromptTokens) + require.Positive(t, usage.CompletionTokens) + require.Positive(t, usage.TotalTokens) + require.Equal(t, "partial", info.AuditResponseContent) + + var completed dto.ResponsesStreamResponse + require.NoError(t, common.UnmarshalJsonStr(sseDataForEvent(t, recorder.Body.String(), "response.completed"), &completed)) + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + require.Equal(t, 20, completed.Response.Usage.InputTokens) + require.Positive(t, completed.Response.Usage.OutputTokens) + require.Positive(t, completed.Response.Usage.TotalTokens) +} + func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *testing.T) { t.Parallel() @@ -318,3 +376,19 @@ func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMiss require.Equal(t, 100, usage.CompletionTokens) require.Equal(t, 110, usage.TotalTokens) } + +func sseDataForEvent(t *testing.T, body string, eventType string) string { + t.Helper() + for _, event := range strings.Split(body, "\n\n") { + if !strings.Contains(event, "event: "+eventType) { + continue + } + for _, line := range strings.Split(event, "\n") { + if strings.HasPrefix(line, "data: ") { + return strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + } + } + } + t.Fatalf("event %q not found in body:\n%s", eventType, body) + return "" +} diff --git a/relay/channel/gemini/relay_responses.go b/relay/channel/gemini/relay_responses.go new file mode 100644 index 0000000..b0535a7 --- /dev/null +++ b/relay/channel/gemini/relay_responses.go @@ -0,0 +1,175 @@ +package gemini + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/logger" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/service/openaicompat" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" +) + +func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + logger.LogDebug(c, "Gemini responses response body: %s", responseBody) + + var geminiResponse dto.GeminiChatResponse + if err := common.Unmarshal(responseBody, &geminiResponse); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if len(geminiResponse.Candidates) == 0 { + usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens()) + if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason)) + return &usage, types.NewOpenAIError( + errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), + types.ErrorCodePromptBlocked, + http.StatusBadRequest, + ) + } + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "gemini_empty_candidates") + return &usage, types.NewOpenAIError( + errors.New("empty response from Gemini API"), + types.ErrorCodeEmptyResponse, + http.StatusInternalServerError, + ) + } + + chatResp := responseGeminiChat2OpenAI(c, &geminiResponse) + chatResp.Model = info.UpstreamModelName + usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens()) + chatResp.Usage = usage + + responsesResp, responsesUsage, err := service.ChatCompletionsResponseToResponsesResponse(chatResp, helper.GetResponseID(c)) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if responsesUsage == nil || responsesUsage.TotalTokens == 0 { + responsesResp.Usage = openaicompat.UsageFromChatUsage(&usage) + } + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, service.BuildGeminiResponseAuditContent(&geminiResponse)) + } + + responseBody, err = common.Marshal(responsesResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + service.IOCopyBytesGracefully(c, resp, responseBody) + return &usage, nil +} + +func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + responseID := helper.GetResponseID(c) + created := common.GetTimestamp() + state := openaicompat.NewChatToResponsesStreamState(responseID, info.UpstreamModelName) + state.Created = created + finishReason := constant.FinishReasonStop + toolCallIndexByChoice := make(map[int]map[string]int) + nextToolCallIndexByChoice := make(map[int]int) + var streamErr *types.MaxAPIError + + sendEvent := func(event openaicompat.ChatToResponsesStreamEvent) bool { + data, err := common.Marshal(event.Payload) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + return false + } + helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)) + return true + } + sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool { + events, err := openaicompat.ChatCompletionsStreamChunkToResponsesEvents(chunk, state) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + for _, event := range events { + if !sendEvent(event) { + return false + } + } + return true + } + + usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { + response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse) + response.Id = responseID + response.Created = created + response.Model = info.UpstreamModelName + + if response.IsToolCall() { + finishReason = constant.FinishReasonToolCalls + } + for choiceIdx := range response.Choices { + choiceKey := response.Choices[choiceIdx].Index + for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls { + tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx] + if tool.ID == "" { + continue + } + indexByID := toolCallIndexByChoice[choiceKey] + if indexByID == nil { + indexByID = make(map[string]int) + toolCallIndexByChoice[choiceKey] = indexByID + } + if idx, ok := indexByID[tool.ID]; ok { + tool.SetIndex(idx) + continue + } + idx := nextToolCallIndexByChoice[choiceKey] + nextToolCallIndexByChoice[choiceKey] = idx + 1 + indexByID[tool.ID] = idx + tool.SetIndex(idx) + } + } + + if !sendChunk(response) { + return false + } + if isStop { + return sendChunk(helper.GenerateStopResponse(responseID, created, info.UpstreamModelName, finishReason)) + } + return true + }) + if err != nil { + return usage, err + } + if streamErr != nil { + return nil, streamErr + } + + if usage != nil { + state.Usage = openaicompat.UsageFromChatUsage(usage) + } + if state.Usage == nil || state.Usage.TotalTokens == 0 { + estimated := service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + state.Usage = openaicompat.UsageFromChatUsage(estimated) + if usage == nil || usage.TotalTokens == 0 { + usage = estimated + } + } + for _, event := range openaicompat.FinalizeChatCompletionsStreamToResponses(state) { + if !sendEvent(event) { + return nil, streamErr + } + } + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, state.UsageText()) + } + return usage, nil +} diff --git a/relay/channel/openai/responses_via_chat.go b/relay/channel/openai/responses_via_chat.go new file mode 100644 index 0000000..be33c86 --- /dev/null +++ b/relay/channel/openai/responses_via_chat.go @@ -0,0 +1,135 @@ +package openai + +import ( + "fmt" + "io" + "net/http" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/logger" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/service/openaicompat" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" +) + +func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + defer service.CloseResponseBodyGracefully(resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + + var chatResp dto.OpenAITextResponse + if err := common.Unmarshal(body, &chatResp); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if oaiError := chatResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + responseID := helper.GetResponseID(c) + responsesResp, usage, err := service.ChatCompletionsResponseToResponsesResponse(&chatResp, responseID) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if usage == nil || usage.TotalTokens == 0 { + text := service.ExtractOutputTextFromResponses(responsesResp) + usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens()) + responsesResp.Usage = openaicompat.UsageFromChatUsage(usage) + } + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, service.ExtractOutputTextFromResponses(responsesResp)) + } + + responseBody, err := common.Marshal(responsesResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + service.IOCopyBytesGracefully(c, resp, responseBody) + return usage, nil +} + +func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + defer service.CloseResponseBodyGracefully(resp) + + responseID := helper.GetResponseID(c) + state := openaicompat.NewChatToResponsesStreamState(responseID, info.UpstreamModelName) + var streamErr *types.MaxAPIError + + sendEvent := func(event openaicompat.ChatToResponsesStreamEvent) bool { + data, err := common.Marshal(event.Payload) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + return false + } + helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)) + return true + } + + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + if streamErr != nil { + sr.Stop(streamErr) + return + } + + var errorResp dto.OpenAITextResponse + if err := common.UnmarshalJsonStr(data, &errorResp); err == nil { + if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode) + sr.Stop(streamErr) + return + } + } + + var chunk dto.ChatCompletionsStreamResponse + if err := common.UnmarshalJsonStr(data, &chunk); err != nil { + logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error()) + sr.Error(err) + return + } + + events, err := openaicompat.ChatCompletionsStreamChunkToResponsesEvents(&chunk, state) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + sr.Stop(streamErr) + return + } + for _, event := range events { + if !sendEvent(event) { + sr.Stop(streamErr) + return + } + } + }) + + if streamErr != nil { + return nil, streamErr + } + + usage := state.Usage + if usage == nil || usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + state.Usage = openaicompat.UsageFromChatUsage(usage) + } + + for _, event := range openaicompat.FinalizeChatCompletionsStreamToResponses(state) { + if !sendEvent(event) { + return nil, streamErr + } + } + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, state.UsageText()) + } + return usage, nil +} diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 08e0ae3..bb9cf8f 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "strconv" + "strings" "time" "github.com/MAX-API-Next/MAX-API/common" @@ -52,13 +53,15 @@ type requestPayload struct { Tools []struct { Type string `json:"type,omitempty"` } `json:"tools,omitempty"` - Resolution string `json:"resolution,omitempty"` - Ratio string `json:"ratio,omitempty"` - Duration *dto.IntValue `json:"duration,omitempty"` - Frames *dto.IntValue `json:"frames,omitempty"` - Seed *dto.IntValue `json:"seed,omitempty"` - CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"` - Watermark *dto.BoolValue `json:"watermark,omitempty"` + SafetyIdentifier *string `json:"safety_identifier,omitempty"` + Priority *dto.IntValue `json:"priority,omitempty"` + Resolution string `json:"resolution,omitempty"` + Ratio string `json:"ratio,omitempty"` + Duration *dto.IntValue `json:"duration,omitempty"` + Frames *dto.IntValue `json:"frames,omitempty"` + Seed *dto.IntValue `json:"seed,omitempty"` + CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"` + Watermark *dto.BoolValue `json:"watermark,omitempty"` } type responsePayload struct { @@ -147,18 +150,52 @@ func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *r return nil } -// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。 +// EstimateBilling 根据请求 metadata 中的输出分辨率与是否包含视频输入,返回相对基准价的计费 OtherRatio。 func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { req, err := relaycommon.GetTaskRequest(c) if err != nil { return nil } - if hasVideoInMetadata(req.Metadata) { - if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok { - return map[string]float64{"video_input": ratio} + resolution, hasVideo := a.resolveSeedanceBillingInputs(c, &req) + ratio, ok := GetVideoInputRatio(info.OriginModelName, resolution, hasVideo) + if !ok || ratio == 1.0 { + return nil + } + return map[string]float64{"video_input": ratio} +} + +func (a *TaskAdaptor) resolveSeedanceBillingInputs(c *gin.Context, req *relaycommon.TaskSubmitReq) (string, bool) { + if req == nil { + return "", false + } + hasVideo := hasVideoInMetadata(req.Metadata) || hasVideoInMetadata(getSeedanceMediaRawRequest(c)) + resolution := req.Resolution + if !a.useSeedanceMediaProtocol() { + payload, err := a.convertToRequestPayload(req) + if err == nil && payload != nil { + if payload.Resolution != "" { + resolution = payload.Resolution + } + hasVideo = hasVideo || hasVideoInContent(payload.Content) } + return resolution, hasVideo } - return nil + if payload, err := a.convertToGenericMediaRequest(req); err == nil && payload != nil { + applySeedanceMediaRawFields(payload, getSeedanceMediaRawRequest(c)) + if payload.Resolution != "" { + resolution = payload.Resolution + } + } + return resolution, hasVideo +} + +func hasVideoInContent(content []ContentItem) bool { + for _, item := range content { + if item.VideoURL != nil && hasUsableVideoInput(item.VideoURL.URL) { + return true + } + } + return false } // hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目, @@ -167,6 +204,12 @@ func hasVideoInMetadata(metadata map[string]interface{}) bool { if metadata == nil { return false } + if videoURL, has := metadata["video_url"]; has && hasUsableVideoInput(videoURL) { + return true + } + if video, has := metadata["video"]; has && hasUsableVideoInput(video) { + return true + } contentRaw, ok := metadata["content"] if !ok { return false @@ -180,16 +223,39 @@ func hasVideoInMetadata(metadata map[string]interface{}) bool { if !ok { continue } - if itemMap["type"] == "video_url" { + if itemMap["type"] == "video_url" && hasUsableVideoInput(itemMap["video_url"]) { return true } - if _, has := itemMap["video_url"]; has { + if videoURL, has := itemMap["video_url"]; has && hasUsableVideoInput(videoURL) { return true } } return false } +func hasUsableVideoInput(value interface{}) bool { + switch v := value.(type) { + case nil: + return false + case string: + return strings.TrimSpace(v) != "" + case bool: + return v + case map[string]interface{}: + if url, has := v["url"]; has { + return hasUsableVideoInput(url) + } + return len(v) > 0 + case map[string]string: + if url, has := v["url"]; has { + return strings.TrimSpace(url) != "" + } + return len(v) > 0 + default: + return true + } +} + // BuildRequestBody converts request into Doubao specific format. func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { req, err := relaycommon.GetTaskRequest(c) diff --git a/relay/channel/task/doubao/adaptor_media_protocol_test.go b/relay/channel/task/doubao/adaptor_media_protocol_test.go index e1c5e46..85e6b54 100644 --- a/relay/channel/task/doubao/adaptor_media_protocol_test.go +++ b/relay/channel/task/doubao/adaptor_media_protocol_test.go @@ -6,6 +6,7 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/model" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -82,3 +83,189 @@ func TestParseSeedanceMediaTaskResult(t *testing.T) { assert.Equal(t, "https://example.com/result.mp4", taskInfo.Url) assert.Equal(t, "6c1a4eeaaee14736a78170ddbfff8361", taskInfo.TaskID) } + +func TestGetVideoInputRatioUsesResolutionAndVideoInput(t *testing.T) { + ratio, ok := GetVideoInputRatio("doubao-seedance-2-0-260128", "", false) + require.True(t, ok) + assert.InDelta(t, 1.0, ratio, 1e-9) + + ratio, ok = GetVideoInputRatio("doubao-seedance-2-0-260128", "1080p", false) + require.True(t, ok) + assert.InDelta(t, 51.0/46.0, ratio, 1e-9) + + ratio, ok = GetVideoInputRatio("doubao-seedance-2-0-260128", "4k", true) + require.True(t, ok) + assert.InDelta(t, 16.0/46.0, ratio, 1e-9) + + ratio, ok = GetVideoInputRatio("doubao-seedance-2-0-fast-260128", "4k", false) + require.True(t, ok) + assert.InDelta(t, 1.0, ratio, 1e-9) + + ratio, ok = GetVideoInputRatio("doubao-seedance-2-0-fast-260128", "4k", true) + require.True(t, ok) + assert.InDelta(t, 22.0/37.0, ratio, 1e-9) +} + +func TestEstimateBillingUsesResolutionAndVideoInput(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Metadata: map[string]any{ + "resolution": "1080p", + "content": []any{ + map[string]any{ + "type": "video_url", + "video_url": map[string]any{ + "url": "https://example.com/input.mp4", + }, + }, + }, + }, + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, 31.0/46.0, ratios["video_input"], 1e-9) +} + +func TestEstimateBillingIgnoresUnusableVideoInput(t *testing.T) { + cases := []struct { + name string + metadata map[string]any + want float64 + }{ + { + name: "empty top-level video_url", + metadata: map[string]any{ + "resolution": "1080p", + "video_url": " ", + }, + want: 51.0 / 46.0, + }, + { + name: "nil top-level video", + metadata: map[string]any{ + "resolution": "1080p", + "video": nil, + }, + want: 51.0 / 46.0, + }, + { + name: "false top-level video", + metadata: map[string]any{ + "resolution": "1080p", + "video": false, + }, + want: 51.0 / 46.0, + }, + { + name: "empty content video_url object", + metadata: map[string]any{ + "resolution": "1080p", + "content": []any{ + map[string]any{ + "type": "video_url", + "video_url": map[string]any{"url": ""}, + }, + }, + }, + want: 51.0 / 46.0, + }, + { + name: "usable content video_url object", + metadata: map[string]any{ + "resolution": "1080p", + "content": []any{ + map[string]any{ + "type": "video_url", + "video_url": map[string]any{"url": "https://example.com/input.mp4"}, + }, + }, + }, + want: 31.0 / 46.0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Metadata: tc.metadata, + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, tc.want, ratios["video_input"], 1e-9) + }) + } +} + +func TestEstimateBillingUsesTopLevelSeedanceMediaResolution(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + Resolution: "1080p", + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, 51.0/46.0, ratios["video_input"], 1e-9) +} + +func TestEstimateBillingUsesLegacyRequestPayloadResolution(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + Resolution: "1080p", + Metadata: map[string]any{ + "resolution": "720p", + }, + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.Nil(t, ratios) +} + +func TestConvertToRequestPayloadPreservesSeedanceFields(t *testing.T) { + var req relaycommon.TaskSubmitReq + err := common.Unmarshal([]byte(`{ + "model": "doubao-seedance-2-0-260128", + "prompt": "test", + "metadata": { + "safety_identifier": "safety-123", + "priority": 0, + "resolution": "4k", + "content": [ + { + "type": "video_url", + "video_url": { "url": "https://example.com/input.mp4" } + } + ] + } + }`), &req) + require.NoError(t, err) + + payload, err := (&TaskAdaptor{}).convertToRequestPayload(&req) + require.NoError(t, err) + require.NotNil(t, payload) + require.NotNil(t, payload.SafetyIdentifier) + assert.Equal(t, "safety-123", *payload.SafetyIdentifier) + require.NotNil(t, payload.Priority) + assert.Equal(t, 0, int(*payload.Priority)) + assert.Equal(t, "4k", payload.Resolution) +} diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go index d65773d..f243755 100644 --- a/relay/channel/task/doubao/constants.go +++ b/relay/channel/task/doubao/constants.go @@ -1,5 +1,7 @@ package doubao +import "strings" + var ModelList = []string{ "doubao-seedance-1-0-pro-250528", "doubao-seedance-1-0-lite-t2v", @@ -11,15 +13,48 @@ var ModelList = []string{ var ChannelName = "doubao-video" -// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。 -// 管理员应将 ModelRatio 设置为"不含视频"的较高费率, -// 系统在检测到视频输入时自动乘以此折扣。 -var videoInputRatioMap = map[string]float64{ - "doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087 - "doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946 +// videoPriceKey 价格表的键:输出分辨率档(is1080p/is4k 均为 false 即 480p/720p 基准档)、输入是否含视频。 +type videoPriceKey struct { + is1080p bool + is4k bool + hasVideo bool +} + +// videoPriceTable 各模型在不同 (输出分辨率档, 是否含视频输入) 下的单价(元/百万 token)。 +// 其中零值键 {480p/720p, 不含视频} 为基准价,等于管理员应配置的 ModelRatio; +// 计费时取 实际单价/基准价 作为 OtherRatio。 +var videoPriceTable = map[string]map[videoPriceKey]float64{ + "doubao-seedance-2-0-260128": { + {hasVideo: false}: 46.0, + {hasVideo: true}: 28.0, + {is1080p: true, hasVideo: false}: 51.0, + {is1080p: true, hasVideo: true}: 31.0, + {is4k: true, hasVideo: false}: 26.0, + {is4k: true, hasVideo: true}: 16.0, + }, + "doubao-seedance-2-0-fast-260128": { + {hasVideo: false}: 37.0, + {hasVideo: true}: 22.0, + {is1080p: true, hasVideo: false}: 37.0, + {is1080p: true, hasVideo: true}: 22.0, + {is4k: true, hasVideo: false}: 37.0, + {is4k: true, hasVideo: true}: 22.0, + }, } -func GetVideoInputRatio(modelName string) (float64, bool) { - r, ok := videoInputRatioMap[modelName] - return r, ok +// GetVideoInputRatio 返回指定模型在给定输出分辨率/是否含视频输入下,相对基准价的计费倍率。 +// 第二个返回值表示该模型是否配置了价格表;倍率为 1.0 时调用方可忽略该 OtherRatio。 +func GetVideoInputRatio(modelName, resolution string, hasVideo bool) (float64, bool) { + prices, ok := videoPriceTable[modelName] + base := prices[videoPriceKey{}] + if !ok || base <= 0 { + return 0, false + } + res := strings.ToLower(strings.TrimSpace(resolution)) + price, ok := prices[videoPriceKey{is1080p: res == "1080p", is4k: res == "4k", hasVideo: hasVideo}] + if !ok { + // 未配置的组合(如 fast 无 1080p/4k,上游会自行报错)按基准价计费即可。 + return 1.0, true + } + return price / base, true } diff --git a/service/convert.go b/service/convert.go index 467ff15..84cd59d 100644 --- a/service/convert.go +++ b/service/convert.go @@ -615,24 +615,23 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco } for _, choice := range openAIResponse.Choices { stopReason = stopReasonOpenAI2Claude(choice.FinishReason) - if choice.FinishReason == "tool_calls" { - for _, toolUse := range choice.Message.ParseToolCalls() { - claudeContent := dto.ClaudeMediaMessage{} - claudeContent.Type = "tool_use" - claudeContent.Id = toolUse.ID - claudeContent.Name = toolUse.Function.Name - var mapParams map[string]interface{} - if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil { - claudeContent.Input = mapParams - } else { - claudeContent.Input = toolUse.Function.Arguments - } - contents = append(contents, claudeContent) - } - } else { + if text := choice.Message.StringContent(); text != "" { claudeContent := dto.ClaudeMediaMessage{} claudeContent.Type = "text" - claudeContent.SetText(choice.Message.StringContent()) + claudeContent.SetText(text) + contents = append(contents, claudeContent) + } + for _, toolUse := range choice.Message.ParseToolCalls() { + claudeContent := dto.ClaudeMediaMessage{} + claudeContent.Type = "tool_use" + claudeContent.Id = toolUse.ID + claudeContent.Name = toolUse.Function.Name + var mapParams map[string]interface{} + if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil { + claudeContent.Input = mapParams + } else { + claudeContent.Input = map[string]interface{}{"arguments": toolUse.Function.Arguments} + } contents = append(contents, claudeContent) } } @@ -863,6 +862,12 @@ func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relayco Parts: make([]dto.GeminiPart, 0), } + // 处理文本内容 + textContent := choice.Message.StringContent() + if textContent != "" { + content.Parts = append(content.Parts, dto.GeminiPart{Text: textContent}) + } + // 处理工具调用 toolCalls := choice.Message.ParseToolCalls() if len(toolCalls) > 0 { @@ -885,15 +890,6 @@ func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relayco } content.Parts = append(content.Parts, part) } - } else { - // 处理文本内容 - textContent := choice.Message.StringContent() - if textContent != "" { - part := dto.GeminiPart{ - Text: textContent, - } - content.Parts = append(content.Parts, part) - } } candidate.Content = content diff --git a/service/convert_test.go b/service/convert_test.go new file mode 100644 index 0000000..8fbcbb0 --- /dev/null +++ b/service/convert_test.go @@ -0,0 +1,86 @@ +package service + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResponseOpenAI2ClaudePreservesTextWithToolCalls(t *testing.T) { + resp := openAIResponseWithTextAndToolCall(t) + got := ResponseOpenAI2Claude(resp, &relaycommon.RelayInfo{}) + + require.NotNil(t, got) + require.Len(t, got.Content, 2) + assert.Equal(t, "text", got.Content[0].Type) + require.NotNil(t, got.Content[0].Text) + assert.Equal(t, "I will look that up.", *got.Content[0].Text) + assert.Equal(t, "tool_use", got.Content[1].Type) + assert.Equal(t, "call_1", got.Content[1].Id) + assert.Equal(t, "lookup", got.Content[1].Name) + assert.Equal(t, map[string]interface{}{"q": "max-api"}, got.Content[1].Input) +} + +func TestResponseOpenAI2ClaudeWrapsMalformedToolArguments(t *testing.T) { + resp := openAIResponseWithToolCallArguments(t, "{") + got := ResponseOpenAI2Claude(resp, &relaycommon.RelayInfo{}) + + require.NotNil(t, got) + require.Len(t, got.Content, 2) + assert.Equal(t, "tool_use", got.Content[1].Type) + assert.Equal(t, map[string]interface{}{"arguments": "{"}, got.Content[1].Input) +} + +func TestResponseOpenAI2GeminiPreservesTextWithToolCalls(t *testing.T) { + resp := openAIResponseWithTextAndToolCall(t) + got := ResponseOpenAI2Gemini(resp, &relaycommon.RelayInfo{}) + + require.NotNil(t, got) + require.Len(t, got.Candidates, 1) + parts := got.Candidates[0].Content.Parts + require.Len(t, parts, 2) + assert.Equal(t, "I will look that up.", parts[0].Text) + require.NotNil(t, parts[1].FunctionCall) + assert.Equal(t, "lookup", parts[1].FunctionCall.FunctionName) + assert.Equal(t, map[string]interface{}{"q": "max-api"}, parts[1].FunctionCall.Arguments) +} + +func openAIResponseWithTextAndToolCall(t *testing.T) *dto.OpenAITextResponse { + return openAIResponseWithToolCallArguments(t, `{"q":"max-api"}`) +} + +func openAIResponseWithToolCallArguments(t *testing.T, arguments string) *dto.OpenAITextResponse { + t.Helper() + rawToolCalls, err := common.Marshal([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "lookup", + Arguments: arguments, + }, + }, + }) + require.NoError(t, err) + + return &dto.OpenAITextResponse{ + Id: "chatcmpl_1", + Model: "gpt-test", + Choices: []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: "I will look that up.", + ToolCalls: rawToolCalls, + }, + FinishReason: constant.FinishReasonToolCalls, + }, + }, + } +} diff --git a/service/openai_chat_responses_compat.go b/service/openai_chat_responses_compat.go index 87de761..dda5db5 100644 --- a/service/openai_chat_responses_compat.go +++ b/service/openai_chat_responses_compat.go @@ -9,10 +9,26 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d return openaicompat.ChatCompletionsRequestToResponsesRequest(req) } +func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) { + return openaicompat.ResponsesRequestToChatCompletionsRequest(req) +} + +func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) { + return openaicompat.ChatCompletionsResponseToResponsesResponse(resp, id) +} + func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) { return openaicompat.ResponsesResponseToChatCompletionsResponse(resp, id) } +func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) { + return openaicompat.ResponsesFinishReasonFromStatus(resp) +} + func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { return openaicompat.ExtractOutputTextFromResponses(resp) } + +func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string { + return openaicompat.ExtractReasoningTextFromResponses(resp) +} diff --git a/service/openaicompat/chat_to_responses_response.go b/service/openaicompat/chat_to_responses_response.go new file mode 100644 index 0000000..71dda1a --- /dev/null +++ b/service/openaicompat/chat_to_responses_response.go @@ -0,0 +1,595 @@ +package openaicompat + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" +) + +const ( + chatFinishReasonLength = "length" + chatFinishReasonContentFilter = "content_filter" +) + +type ChatToResponsesStreamEvent struct { + Type string + Payload dto.ResponsesStreamResponse +} + +type ChatToResponsesStreamState struct { + ID string + Model string + Created int64 + Usage *dto.Usage + + status string + incompleteDetails *dto.IncompleteDetails + sentCreated bool + textOutputIndex int + textStarted bool + textDone bool + reasoningIndex int + reasoningStarted bool + reasoningDone bool + finalized bool + nextOutputIndex int + toolsByIndex map[int]*chatToResponsesStreamTool + outputOrder []chatToResponsesOutputRef + text strings.Builder + reasoning strings.Builder +} + +type chatToResponsesStreamTool struct { + ChatIndex int + OutputIndex int + ID string + Name string + Arguments strings.Builder + Done bool +} + +type chatToResponsesOutputRef struct { + Kind string + ToolIndex int +} + +func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) { + if resp == nil { + return nil, nil, errors.New("response is nil") + } + + usage := UsageFromChatUsage(&resp.Usage) + out := &dto.OpenAIResponsesResponse{ + ID: id, + Object: "response", + CreatedAt: chatCreatedAt(resp.Created), + Status: []byte(`"completed"`), + Model: resp.Model, + Output: make([]dto.ResponsesOutput, 0), + Usage: usage, + } + if len(resp.Choices) == 0 { + return out, usage, nil + } + + choice := resp.Choices[0] + if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" { + out.Status = []byte(fmt.Sprintf("%q", status)) + out.IncompleteDetails = details + } + status := responseOutputStatus(out) + + if text := choice.Message.StringContent(); text != "" { + out.Output = append(out.Output, dto.ResponsesOutput{ + Type: responsesOutputTypeMessage, + ID: fmt.Sprintf("%s_msg_0", id), + Status: status, + Role: "assistant", + Content: []dto.ResponsesOutputContent{{ + Type: "output_text", + Text: text, + Annotations: []interface{}{}, + }}, + }) + } + if reasoning := choice.Message.GetReasoningContent(); reasoning != "" { + out.Output = append(out.Output, dto.ResponsesOutput{ + Type: responsesOutputTypeReasoning, + ID: fmt.Sprintf("%s_reasoning_0", id), + Status: status, + Content: []dto.ResponsesOutputContent{{ + Type: "summary_text", + Text: reasoning, + }}, + }) + } + for i, toolCall := range choice.Message.ParseToolCalls() { + toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, status) + if err != nil { + return nil, nil, err + } + out.Output = append(out.Output, toolOutput) + } + + return out, usage, nil +} + +func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) { + switch strings.TrimSpace(finishReason) { + case chatFinishReasonLength: + return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens} + case chatFinishReasonContentFilter: + return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter} + default: + return "completed", nil + } +} + +func UsageFromChatUsage(src *dto.Usage) *dto.Usage { + usage := &dto.Usage{} + if src == nil { + return usage + } + if src.PromptTokens != 0 { + usage.PromptTokens = src.PromptTokens + usage.InputTokens = src.PromptTokens + } + if src.CompletionTokens != 0 { + usage.CompletionTokens = src.CompletionTokens + usage.OutputTokens = src.CompletionTokens + } + if src.TotalTokens != 0 { + usage.TotalTokens = src.TotalTokens + } else { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + } + if src.PromptTokensDetails.CachedTokens != 0 || + src.PromptTokensDetails.ImageTokens != 0 || + src.PromptTokensDetails.AudioTokens != 0 || + src.PromptTokensDetails.CachedCreationTokens != 0 || + src.PromptTokensDetails.TextTokens != 0 { + details := src.PromptTokensDetails + usage.InputTokensDetails = &details + } + if src.CompletionTokenDetails.ReasoningTokens != 0 || + src.CompletionTokenDetails.TextTokens != 0 || + src.CompletionTokenDetails.AudioTokens != 0 || + src.CompletionTokenDetails.ImageTokens != 0 { + usage.CompletionTokenDetails = src.CompletionTokenDetails + } + return usage +} + +func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState { + return &ChatToResponsesStreamState{ + ID: id, + Model: model, + Created: time.Now().Unix(), + Usage: &dto.Usage{}, + status: "completed", + textOutputIndex: -1, + reasoningIndex: -1, + toolsByIndex: make(map[int]*chatToResponsesStreamTool), + } +} + +func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) { + if chunk == nil || state == nil { + return nil, nil + } + if state.ID == "" { + state.ID = chunk.Id + } + if state.Model == "" { + state.Model = chunk.Model + } + if state.Created == 0 { + state.Created = chunk.Created + } + if chunk.Usage != nil { + state.Usage = UsageFromChatUsage(chunk.Usage) + } + + events := make([]ChatToResponsesStreamEvent, 0) + if !state.sentCreated { + state.sentCreated = true + events = append(events, responsesStreamEvent(responsesEventCreated, dto.ResponsesStreamResponse{ + Type: responsesEventCreated, + Response: state.createdResponse(), + })) + } + for _, choice := range chunk.Choices { + if choice.Delta.GetReasoningContent() != "" { + events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...) + } + if choice.Delta.GetContentString() != "" { + events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...) + } + for _, toolCall := range choice.Delta.ToolCalls { + toolEvents, err := state.appendToolCallDelta(toolCall) + if err != nil { + return nil, err + } + events = append(events, toolEvents...) + } + if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" { + state.applyFinishReason(*choice.FinishReason) + events = append(events, state.doneDeltaEvents()...) + } + } + return events, nil +} + +func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent { + if state == nil || state.finalized { + return nil + } + events := state.doneDeltaEvents() + state.finalized = true + eventType := responsesEventCompleted + if state.status == "incomplete" { + eventType = responsesEventIncomplete + } + events = append(events, responsesStreamEvent(eventType, dto.ResponsesStreamResponse{ + Type: eventType, + Response: state.finalResponse(), + })) + return events +} + +func (s *ChatToResponsesStreamState) UsageText() string { + if s == nil { + return "" + } + return s.text.String() +} + +func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent { + events := make([]ChatToResponsesStreamEvent, 0, 2) + if !s.textStarted { + s.textStarted = true + s.textOutputIndex = s.nextIndex("message", -1) + events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: intPtr(s.textOutputIndex), + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeMessage, + ID: s.messageID(), + Status: "in_progress", + Role: "assistant", + Content: []dto.ResponsesOutputContent{}, + }, + })) + } + s.text.WriteString(delta) + events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{ + Type: responsesEventOutputTextDelta, + OutputIndex: intPtr(s.textOutputIndex), + ContentIndex: intPtr(0), + Delta: delta, + ItemID: s.messageID(), + })) + return events +} + +func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent { + events := make([]ChatToResponsesStreamEvent, 0, 2) + if !s.reasoningStarted { + s.reasoningStarted = true + s.reasoningIndex = s.nextIndex("reasoning", -1) + events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: intPtr(s.reasoningIndex), + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeReasoning, + ID: s.reasoningID(), + Status: "in_progress", + Content: []dto.ResponsesOutputContent{}, + }, + })) + } + s.reasoning.WriteString(delta) + events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{ + Type: responsesEventReasoningSummaryDelta, + OutputIndex: intPtr(s.reasoningIndex), + SummaryIndex: intPtr(0), + Delta: delta, + ItemID: s.reasoningID(), + })) + return events +} + +func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallResponse) ([]ChatToResponsesStreamEvent, error) { + chatIndex := 0 + if toolCall.Index != nil { + chatIndex = *toolCall.Index + } + tool := s.toolsByIndex[chatIndex] + events := make([]ChatToResponsesStreamEvent, 0, 2) + if tool == nil { + tool = &chatToResponsesStreamTool{ + ChatIndex: chatIndex, + OutputIndex: s.nextIndex("tool", chatIndex), + ID: strings.TrimSpace(toolCall.ID), + Name: strings.TrimSpace(toolCall.Function.Name), + } + if tool.ID == "" { + tool.ID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex) + } + s.toolsByIndex[chatIndex] = tool + events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: intPtr(tool.OutputIndex), + ItemID: tool.ID, + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + ID: tool.ID, + Status: "in_progress", + CallId: tool.ID, + Name: tool.Name, + Arguments: []byte(`""`), + }, + })) + } + if strings.TrimSpace(toolCall.ID) != "" { + tool.ID = strings.TrimSpace(toolCall.ID) + } + if strings.TrimSpace(toolCall.Function.Name) != "" { + tool.Name = strings.TrimSpace(toolCall.Function.Name) + } + if toolCall.Function.Arguments != "" { + tool.Arguments.WriteString(toolCall.Function.Arguments) + events = append(events, responsesStreamEvent(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDelta, + OutputIndex: intPtr(tool.OutputIndex), + ItemID: tool.ID, + Delta: toolCall.Function.Arguments, + })) + } + return events, nil +} + +func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent { + events := make([]ChatToResponsesStreamEvent, 0) + status := s.outputStatus() + if s.textStarted && !s.textDone { + s.textDone = true + events = append(events, responsesStreamEvent("response.output_text.done", dto.ResponsesStreamResponse{ + Type: "response.output_text.done", + OutputIndex: intPtr(s.textOutputIndex), + ContentIndex: intPtr(0), + ItemID: s.messageID(), + })) + events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemDone, + OutputIndex: intPtr(s.textOutputIndex), + Item: s.messageOutput(status), + })) + } + if s.reasoningStarted && !s.reasoningDone { + s.reasoningDone = true + events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDone, dto.ResponsesStreamResponse{ + Type: responsesEventReasoningSummaryDone, + OutputIndex: intPtr(s.reasoningIndex), + SummaryIndex: intPtr(0), + ItemID: s.reasoningID(), + Part: &dto.ResponsesReasoningSummaryPart{ + Type: "summary_text", + Text: s.reasoning.String(), + }, + })) + events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemDone, + OutputIndex: intPtr(s.reasoningIndex), + Item: s.reasoningOutput(status), + })) + } + for _, tool := range s.sortedTools() { + if tool.Done { + continue + } + tool.Done = true + events = append(events, responsesStreamEvent(responsesEventFunctionArgsDone, dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDone, + OutputIndex: intPtr(tool.OutputIndex), + ItemID: tool.ID, + })) + events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemDone, + OutputIndex: intPtr(tool.OutputIndex), + Item: s.toolOutput(tool, status), + })) + } + return events +} + +func (s *ChatToResponsesStreamState) applyFinishReason(finishReason string) { + if status, details := ResponsesStatusFromChatFinishReason(finishReason); status != "" { + s.status = status + s.incompleteDetails = details + } +} + +func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesResponse { + output := make([]dto.ResponsesOutput, 0, len(s.outputOrder)) + status := s.outputStatus() + for _, ref := range s.outputOrder { + switch ref.Kind { + case "message": + output = append(output, *s.messageOutput(status)) + case "reasoning": + output = append(output, *s.reasoningOutput(status)) + case "tool": + if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil { + output = append(output, *s.toolOutput(tool, status)) + } + } + } + return &dto.OpenAIResponsesResponse{ + ID: s.ID, + Object: "response", + CreatedAt: int(s.Created), + Status: []byte(fmt.Sprintf("%q", s.status)), + IncompleteDetails: s.incompleteDetails, + Model: s.Model, + Output: output, + Usage: s.Usage, + } +} + +func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesResponse { + return &dto.OpenAIResponsesResponse{ + ID: s.ID, + Object: "response", + CreatedAt: int(s.Created), + Status: []byte(`"in_progress"`), + Model: s.Model, + Output: []dto.ResponsesOutput{}, + } +} + +func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int { + index := s.nextOutputIndex + s.nextOutputIndex++ + s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex}) + return index +} + +func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool { + indexes := make([]int, 0, len(s.toolsByIndex)) + for index := range s.toolsByIndex { + indexes = append(indexes, index) + } + sort.Ints(indexes) + tools := make([]*chatToResponsesStreamTool, 0, len(indexes)) + for _, index := range indexes { + tools = append(tools, s.toolsByIndex[index]) + } + return tools +} + +func (s *ChatToResponsesStreamState) outputStatus() string { + if s.status == "incomplete" { + return "incomplete" + } + return "completed" +} + +func (s *ChatToResponsesStreamState) messageID() string { + return fmt.Sprintf("%s_msg_0", s.ID) +} + +func (s *ChatToResponsesStreamState) reasoningID() string { + return fmt.Sprintf("%s_reasoning_0", s.ID) +} + +func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput { + return &dto.ResponsesOutput{ + Type: responsesOutputTypeMessage, + ID: s.messageID(), + Status: status, + Role: "assistant", + Content: []dto.ResponsesOutputContent{{ + Type: "output_text", + Text: s.text.String(), + Annotations: []interface{}{}, + }}, + } +} + +func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.ResponsesOutput { + return &dto.ResponsesOutput{ + Type: responsesOutputTypeReasoning, + ID: s.reasoningID(), + Status: status, + Content: []dto.ResponsesOutputContent{{ + Type: "summary_text", + Text: s.reasoning.String(), + }}, + } +} + +func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput { + return &dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + ID: tool.ID, + Status: status, + CallId: tool.ID, + Name: tool.Name, + Arguments: chatArgumentsRawMessage(tool.Arguments.String()), + } +} + +func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string { + if resp == nil || responseStatusString(resp) != "incomplete" { + return "completed" + } + return "incomplete" +} + +func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) { + callID := strings.TrimSpace(toolCall.ID) + if callID == "" { + callID = fmt.Sprintf("%s_call_%d", responseID, index) + } + if toolCall.Type == "" || toolCall.Type == "function" { + return dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + ID: callID, + Status: status, + CallId: callID, + Name: toolCall.Function.Name, + Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments), + }, nil + } + return dto.ResponsesOutput{ + Type: toolCall.Type, + ID: callID, + Status: status, + CallId: callID, + Arguments: toolCall.Custom, + }, nil +} + +func chatArgumentsRawMessage(arguments string) []byte { + raw, err := common.Marshal(arguments) + if err != nil { + return []byte(`""`) + } + return raw +} + +func chatCreatedAt(created any) int { + switch v := created.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case float32: + return int(v) + case string: + if parsed := common.String2Int(v); parsed != 0 { + return parsed + } + } + return int(time.Now().Unix()) +} + +func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent { + payload.Type = eventType + return ChatToResponsesStreamEvent{ + Type: eventType, + Payload: payload, + } +} + +func intPtr(v int) *int { + return &v +} diff --git a/service/openaicompat/responses_compat_test.go b/service/openaicompat/responses_compat_test.go new file mode 100644 index 0000000..b6dc3f8 --- /dev/null +++ b/service/openaicompat/responses_compat_test.go @@ -0,0 +1,323 @@ +package openaicompat + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestResponsesRequestToChatCompletionsRequestInstructionsScalarInputAndZeroTemperature(t *testing.T) { + stream := true + temperature := 0.0 + maxOutputTokens := uint(128) + parallelToolCalls := false + + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Instructions: mustCompatRawMessage(t, "system rules"), + Input: mustCompatRawMessage(t, "hello"), + Stream: &stream, + MaxOutputTokens: &maxOutputTokens, + Temperature: &temperature, + ParallelToolCalls: mustCompatRawMessage(t, parallelToolCalls), + Reasoning: &dto.Reasoning{Effort: "medium"}, + }) + require.NoError(t, err) + + assert.Equal(t, "gpt-test", got.Model) + require.Len(t, got.Messages, 2) + assert.Equal(t, "system", got.Messages[0].Role) + assert.Equal(t, "system rules", got.Messages[0].StringContent()) + assert.Equal(t, "user", got.Messages[1].Role) + assert.Equal(t, "hello", got.Messages[1].StringContent()) + assert.Same(t, &stream, got.Stream) + assert.Equal(t, maxOutputTokens, lo.FromPtr(got.MaxCompletionTokens)) + require.NotNil(t, got.Temperature) + assert.Equal(t, 0.0, *got.Temperature) + require.NotNil(t, got.ParallelTooCalls) + assert.False(t, *got.ParallelTooCalls) + assert.Equal(t, "medium", got.ReasoningEffort) +} + +func TestResponsesRequestToChatCompletionsRequestFunctionCallConversation(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, []map[string]any{ + { + "role": "assistant", + "content": []map[string]any{ + {"type": "output_text", "text": "I will call."}, + }, + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "lookup", + "arguments": map[string]any{"q": "x"}, + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": map[string]any{"ok": true}, + }, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 2) + assert.Equal(t, "assistant", got.Messages[0].Role) + assert.Equal(t, "I will call.", got.Messages[0].StringContent()) + toolCalls := got.Messages[0].ParseToolCalls() + require.Len(t, toolCalls, 1) + assert.Equal(t, "call_1", toolCalls[0].ID) + assert.Equal(t, "lookup", toolCalls[0].Function.Name) + assert.JSONEq(t, `{"q":"x"}`, toolCalls[0].Function.Arguments) + assert.Equal(t, "tool", got.Messages[1].Role) + assert.Equal(t, "call_1", got.Messages[1].ToolCallId) + assert.JSONEq(t, `{"ok":true}`, got.Messages[1].StringContent()) +} + +func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, "hello"), + Tools: mustCompatRawMessage(t, []map[string]any{ + { + "type": "function", + "name": "lookup", + "description": "Lookup data", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "q": map[string]any{"type": "string"}, + }, + }, + }, + }), + ToolChoice: mustCompatRawMessage(t, map[string]any{ + "type": "function", + "name": "lookup", + }), + Text: mustCompatRawMessage(t, map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "answer", + "schema": map[string]any{"type": "object"}, + "strict": true, + }, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Tools, 1) + assert.Equal(t, "function", got.Tools[0].Type) + assert.Equal(t, "lookup", got.Tools[0].Function.Name) + assert.Equal(t, "Lookup data", got.Tools[0].Function.Description) + assert.Equal(t, "object", got.Tools[0].Function.Parameters.(map[string]any)["type"]) + assert.Equal(t, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "lookup", + }, + }, got.ToolChoice) + require.NotNil(t, got.ResponseFormat) + assert.Equal(t, "json_schema", got.ResponseFormat.Type) + assert.False(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "type").Exists()) + assert.Equal(t, "answer", gjson.GetBytes(got.ResponseFormat.JsonSchema, "name").String()) + assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool()) + assert.Equal(t, "object", gjson.GetBytes(got.ResponseFormat.JsonSchema, "schema.type").String()) +} + +func TestResponsesRequestToChatCompletionsRequestSkipsNonMessageOutputItems(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, []map[string]any{ + { + "type": "message", + "role": "assistant", + "content": []map[string]any{ + {"type": "output_text", "text": "answer"}, + }, + }, + { + "type": "reasoning", + "content": []map[string]any{ + {"type": "summary_text", "text": "thinking"}, + }, + }, + {"type": "web_search_call", "id": "ws_1", "status": "completed"}, + {"type": "file_search_call", "id": "fs_1", "status": "completed"}, + {"type": "computer_call", "id": "cc_1", "status": "completed"}, + {"type": "item_reference", "id": "item_1"}, + {"type": "unknown", "content": "do not replay"}, + { + "role": "user", + "content": "continue", + }, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 2) + assert.Equal(t, "assistant", got.Messages[0].Role) + assert.Equal(t, "answer", got.Messages[0].StringContent()) + assert.Equal(t, "user", got.Messages[1].Role) + assert.Equal(t, "continue", got.Messages[1].StringContent()) +} + +func TestResponsesRequestToChatCompletionsRequestRejectsCustomToolCallItems(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, []map[string]any{ + { + "type": "custom_tool_call", + "call_id": "call_custom", + "name": "apply_patch", + "input": "patch body", + }, + }), + }) + + require.Error(t, err) + require.Nil(t, got) + assert.Contains(t, err.Error(), "custom_tool_call") +} + +func TestResponsesRequestToChatCompletionsRequestRejectsUnsupportedToolTypes(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, "hello"), + Tools: mustCompatRawMessage(t, []map[string]any{ + {"type": "custom", "name": "apply_patch"}, + }), + }) + + require.Error(t, err) + require.Nil(t, got) + assert.Contains(t, err.Error(), `tool type "custom"`) +} + +func TestChatCompletionsResponseToResponsesResponsePreservesToolCallsAndIncompleteReason(t *testing.T) { + chat := &dto.OpenAITextResponse{ + Id: "chatcmpl_1", + Model: "gpt-test", + Created: 456, + Choices: []dto.OpenAITextResponseChoice{ + { + Message: compatAssistantMessageWithTool("partial", "call_1", "lookup", `{"q":"x"}`), + FinishReason: "length", + }, + }, + Usage: dto.Usage{PromptTokens: 3, CompletionTokens: 5, TotalTokens: 8}, + } + + resp, usage, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1") + require.NoError(t, err) + require.NotNil(t, usage) + + assert.Equal(t, "resp_1", resp.ID) + assert.Equal(t, `"incomplete"`, string(resp.Status)) + require.NotNil(t, resp.IncompleteDetails) + assert.Equal(t, responsesIncompleteReasonMaxTokens, resp.IncompleteDetails.Reason) + assert.Equal(t, 3, resp.Usage.InputTokens) + assert.Equal(t, 5, resp.Usage.OutputTokens) + require.Len(t, resp.Output, 2) + assert.Equal(t, responsesOutputTypeMessage, resp.Output[0].Type) + assert.Equal(t, "partial", resp.Output[0].Content[0].Text) + assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type) + assert.Equal(t, "call_1", resp.Output[1].CallId) + assert.Equal(t, "lookup", resp.Output[1].Name) + assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments)) +} + +func TestChatCompletionsStreamToResponsesEventsAggregatesUsage(t *testing.T) { + state := NewChatToResponsesStreamState("resp_1", "gpt-test") + state.Created = 123 + toolIndex := 0 + finishReason := "tool_calls" + + var events []ChatToResponsesStreamEvent + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Id: "chatcmpl_1", + Model: "gpt-test", + Created: 123, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("hello")}}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{ + {Index: &toolIndex, ID: "call_1", Type: "function", Function: dto.FunctionResponse{Name: "lookup"}}, + }}}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{ + {Index: &toolIndex, Function: dto.FunctionResponse{Arguments: `{"q":"x"}`}}, + }}}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, FinishReason: &finishReason}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 4, TotalTokens: 6}, + })...) + events = append(events, FinalizeChatCompletionsStreamToResponses(state)...) + + require.Len(t, events, 10) + assert.Equal(t, responsesEventCreated, events[0].Type) + assert.Equal(t, responsesEventOutputTextDelta, events[2].Type) + assert.Equal(t, "hello", events[2].Payload.Delta) + assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type) + assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta) + assert.Equal(t, responsesEventCompleted, events[9].Type) + require.NotNil(t, events[9].Payload.Response) + assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens) + require.Len(t, events[9].Payload.Response.Output, 2) + assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text) + assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments)) +} + +func compatAssistantMessageWithTool(content string, id string, name string, args string) dto.Message { + msg := dto.Message{Role: "assistant", Content: content} + msg.SetToolCalls([]dto.ToolCallRequest{ + { + ID: id, + Type: "function", + Function: dto.FunctionRequest{ + Name: name, + Arguments: args, + }, + }, + }) + return msg +} + +func mustCompatResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent { + t.Helper() + events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state) + require.NoError(t, err) + return events +} + +func mustCompatRawMessage(t *testing.T, value any) []byte { + t.Helper() + raw, err := common.Marshal(value) + require.NoError(t, err) + return raw +} diff --git a/service/openaicompat/responses_request_to_chat.go b/service/openaicompat/responses_request_to_chat.go new file mode 100644 index 0000000..19fc53c --- /dev/null +++ b/service/openaicompat/responses_request_to_chat.go @@ -0,0 +1,480 @@ +package openaicompat + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" +) + +const ( + responsesInputTypeMessage = "message" + responsesInputTypeFunctionCall = "function_call" + responsesInputTypeFunctionCallOutput = "function_call_output" + responsesInputTypeCustomToolCall = "custom_tool_call" +) + +func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) { + if req == nil { + return nil, errors.New("request is nil") + } + if req.Model == "" { + return nil, errors.New("model is required") + } + if err := validateResponsesRequestChatUnsupportedFields(req); err != nil { + return nil, err + } + + messages, err := responsesRequestMessagesToChat(req) + if err != nil { + return nil, err + } + tools, err := responsesRequestToolsToChat(req.Tools) + if err != nil { + return nil, err + } + toolChoice, err := responsesRequestToolChoiceToChat(req.ToolChoice) + if err != nil { + return nil, err + } + responseFormat, err := responsesRequestTextToChatResponseFormat(req.Text) + if err != nil { + return nil, err + } + + out := &dto.GeneralOpenAIRequest{ + Model: req.Model, + Messages: messages, + Stream: req.Stream, + StreamOptions: req.StreamOptions, + MaxCompletionTokens: req.MaxOutputTokens, + Temperature: req.Temperature, + TopP: req.TopP, + TopLogProbs: req.TopLogProbs, + ResponseFormat: responseFormat, + Tools: tools, + ToolChoice: toolChoice, + User: req.User, + Store: req.Store, + Metadata: req.Metadata, + SafetyIdentifier: req.SafetyIdentifier, + PromptCacheRetention: req.PromptCacheRetention, + EnableThinking: req.EnableThinking, + } + if req.Reasoning != nil { + out.ReasoningEffort = req.Reasoning.Effort + } + if req.ServiceTier != "" { + out.ServiceTier, _ = common.Marshal(req.ServiceTier) + } + if len(req.ParallelToolCalls) > 0 && common.GetJsonType(req.ParallelToolCalls) == "boolean" { + var parallelToolCalls bool + if err := common.Unmarshal(req.ParallelToolCalls, ¶llelToolCalls); err == nil { + out.ParallelTooCalls = ¶llelToolCalls + } + } + if len(req.PromptCacheKey) > 0 && common.GetJsonType(req.PromptCacheKey) == "string" { + var promptCacheKey string + if err := common.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil { + out.PromptCacheKey = promptCacheKey + } + } + + return out, nil +} + +func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error { + unsupported := make([]string, 0, 4) + if rawJSONPresent(req.Conversation) { + unsupported = append(unsupported, "conversation") + } + if strings.TrimSpace(req.PreviousResponseID) != "" { + unsupported = append(unsupported, "previous_response_id") + } + if rawJSONPresent(req.Prompt) { + unsupported = append(unsupported, "prompt") + } + if rawJSONPresent(req.ContextManagement) { + unsupported = append(unsupported, "context_management") + } + if len(unsupported) > 0 { + return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", ")) + } + return nil +} + +func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) { + messages := make([]dto.Message, 0) + if rawJSONPresent(req.Instructions) { + instructions, err := responsesJSONString(req.Instructions) + if err != nil { + return nil, fmt.Errorf("invalid instructions: %w", err) + } + if strings.TrimSpace(instructions) != "" { + messages = append(messages, dto.Message{Role: "system", Content: instructions}) + } + } + + if !rawJSONPresent(req.Input) { + return messages, nil + } + switch common.GetJsonType(req.Input) { + case "string": + input, err := responsesJSONString(req.Input) + if err != nil { + return nil, fmt.Errorf("invalid input string: %w", err) + } + return append(messages, dto.Message{Role: "user", Content: input}), nil + case "array": + var items []map[string]any + if err := common.Unmarshal(req.Input, &items); err != nil { + return nil, fmt.Errorf("invalid input array: %w", err) + } + for _, item := range items { + nextMessages, err := responsesInputItemToChatMessages(item, messages) + if err != nil { + return nil, err + } + messages = nextMessages + } + return messages, nil + default: + return nil, fmt.Errorf("unsupported responses input type %q", common.GetJsonType(req.Input)) + } +} + +func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) { + itemType := strings.TrimSpace(common.Interface2String(item["type"])) + switch itemType { + case responsesInputTypeFunctionCall: + toolCall, err := responsesFunctionCallItemToChatToolCall(item) + if err != nil { + return nil, err + } + return appendToolCallToLastAssistant(messages, toolCall), nil + case responsesInputTypeCustomToolCall: + return nil, errors.New("responses to chat conversion does not support custom_tool_call items") + case responsesInputTypeFunctionCallOutput: + callID := strings.TrimSpace(common.Interface2String(item["call_id"])) + content := responseToolOutputToChatContent(item["output"]) + return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil + } + if !responsesInputItemHasChatMessageShape(itemType, item) { + return messages, nil + } + + role := strings.TrimSpace(common.Interface2String(item["role"])) + if role == "" { + role = "user" + } + content, err := responsesInputContentToChatContent(item["content"]) + if err != nil { + return nil, err + } + return append(messages, dto.Message{Role: role, Content: content}), nil +} + +func responsesInputItemHasChatMessageShape(itemType string, item map[string]any) bool { + if itemType != "" && itemType != responsesInputTypeMessage { + return false + } + if itemType == responsesInputTypeMessage { + return true + } + if strings.TrimSpace(common.Interface2String(item["role"])) != "" { + return true + } + _, hasContent := item["content"] + return hasContent +} + +func responsesInputContentToChatContent(content any) (any, error) { + if content == nil { + return "", nil + } + switch value := content.(type) { + case string: + return value, nil + case []any: + return responsesContentPartsToChatContent(value) + case []map[string]any: + parts := make([]any, 0, len(value)) + for _, part := range value { + parts = append(parts, part) + } + return responsesContentPartsToChatContent(parts) + default: + return content, nil + } +} + +func responsesContentPartsToChatContent(parts []any) (any, error) { + chatParts := make([]any, 0, len(parts)) + var textOnly strings.Builder + onlyText := true + + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok { + onlyText = false + chatParts = append(chatParts, rawPart) + continue + } + + switch strings.TrimSpace(common.Interface2String(part["type"])) { + case "input_text", "output_text", "text": + text := common.Interface2String(part["text"]) + textOnly.WriteString(text) + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeText, "text": text}) + case "input_image": + onlyText = false + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeImageURL, "image_url": responsesImagePartToChatImageURL(part)}) + case "input_file": + onlyText = false + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeFile, "file": responsesFilePartToChatFile(part)}) + case "input_audio": + onlyText = false + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeInputAudio, "input_audio": responsesPartPayload(part, "input_audio")}) + case "input_video": + onlyText = false + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeVideoUrl, "video_url": responsesVideoPartToChatVideoURL(part)}) + default: + onlyText = false + chatParts = append(chatParts, part) + } + } + if onlyText { + return textOnly.String(), nil + } + return chatParts, nil +} + +func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) { + name := strings.TrimSpace(common.Interface2String(item["name"])) + if name == "" { + return dto.ToolCallRequest{}, errors.New("function_call item is missing name") + } + return dto.ToolCallRequest{ + ID: responsesCallID(item), + Type: "function", + Function: dto.FunctionRequest{ + Name: name, + Arguments: responsesArgumentsString(item["arguments"]), + }, + }, nil +} + +func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message { + if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" { + messages = append(messages, dto.Message{Role: "assistant"}) + } + idx := len(messages) - 1 + toolCalls := messages[idx].ParseToolCalls() + toolCalls = append(toolCalls, toolCall) + toolCallsRaw, _ := common.Marshal(toolCalls) + messages[idx].ToolCalls = toolCallsRaw + return messages +} + +func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) { + if !rawJSONPresent(raw) { + return nil, nil + } + var tools []map[string]any + if err := common.Unmarshal(raw, &tools); err != nil { + return nil, fmt.Errorf("invalid tools: %w", err) + } + out := make([]dto.ToolCallRequest, 0, len(tools)) + for _, tool := range tools { + toolType := strings.TrimSpace(common.Interface2String(tool["type"])) + if toolType == "function" { + out = append(out, dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: strings.TrimSpace(common.Interface2String(tool["name"])), + Description: common.Interface2String(tool["description"]), + Parameters: tool["parameters"], + }, + }) + continue + } + return nil, fmt.Errorf("responses to chat conversion does not support tool type %q", toolType) + } + return out, nil +} + +func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) { + if !rawJSONPresent(raw) { + return nil, nil + } + if common.GetJsonType(raw) == "string" { + var choice string + if err := common.Unmarshal(raw, &choice); err != nil { + return nil, fmt.Errorf("invalid tool_choice: %w", err) + } + return choice, nil + } + var choice map[string]any + if err := common.Unmarshal(raw, &choice); err != nil { + return nil, fmt.Errorf("invalid tool_choice: %w", err) + } + if common.Interface2String(choice["type"]) == "function" { + if name := strings.TrimSpace(common.Interface2String(choice["name"])); name != "" { + return map[string]any{"type": "function", "function": map[string]any{"name": name}}, nil + } + } + return choice, nil +} + +func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) { + if !rawJSONPresent(raw) { + return nil, nil + } + var textConfig map[string]any + if err := common.Unmarshal(raw, &textConfig); err != nil { + return nil, fmt.Errorf("invalid text config: %w", err) + } + format, ok := textConfig["format"].(map[string]any) + if !ok { + return nil, nil + } + formatType := strings.TrimSpace(common.Interface2String(format["type"])) + if formatType == "" { + return nil, nil + } + out := &dto.ResponseFormat{Type: formatType} + if formatType == "json_schema" { + schema := make(map[string]any, len(format)) + for key, value := range format { + if key == "type" { + continue + } + schema[key] = value + } + schemaRaw, err := common.Marshal(schema) + if err != nil { + return nil, err + } + out.JsonSchema = schemaRaw + } + return out, nil +} + +func responsesImagePartToChatImageURL(part map[string]any) any { + if imageURL, ok := part["image_url"]; ok { + return imageURL + } + imageURL := map[string]any{} + for _, key := range []string{"url", "file_id", "detail"} { + if value, ok := part[key]; ok { + imageURL[key] = value + } + } + if len(imageURL) == 0 { + return part + } + return imageURL +} + +func responsesFilePartToChatFile(part map[string]any) any { + if file, ok := part["file"]; ok { + return file + } + file := map[string]any{} + for _, key := range []string{"file_id", "file_data", "filename", "file_url"} { + if value, ok := part[key]; ok { + file[key] = value + } + } + if len(file) == 0 { + return part + } + return file +} + +func responsesVideoPartToChatVideoURL(part map[string]any) any { + if videoURL, ok := part["video_url"]; ok { + if videoURLMap, ok := videoURL.(map[string]any); ok { + if url := common.Interface2String(videoURLMap["url"]); url != "" { + return url + } + } + return videoURL + } + if url := common.Interface2String(part["url"]); url != "" { + return url + } + return responsesPartPayload(part, "video_url") +} + +func responsesPartPayload(part map[string]any, key string) any { + if value, ok := part[key]; ok { + return value + } + payload := make(map[string]any, len(part)) + for k, value := range part { + if k == "type" { + continue + } + payload[k] = value + } + return payload +} + +func responsesCallID(item map[string]any) string { + if callID := strings.TrimSpace(common.Interface2String(item["call_id"])); callID != "" { + return callID + } + return strings.TrimSpace(common.Interface2String(item["id"])) +} + +func responsesArgumentsString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + default: + raw, err := common.Marshal(v) + if err != nil { + return common.Interface2String(v) + } + return string(raw) + } +} + +func responseToolOutputToChatContent(value any) any { + switch v := value.(type) { + case nil: + return "" + case string: + return v + default: + raw, err := common.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(raw) + } +} + +func responsesJSONString(raw json.RawMessage) (string, error) { + if common.GetJsonType(raw) != "string" { + return string(raw), nil + } + var value string + if err := common.Unmarshal(raw, &value); err != nil { + return "", err + } + return value, nil +} + +func rawJSONPresent(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + return common.GetJsonType(raw) != "null" +} diff --git a/service/openaicompat/responses_to_chat.go b/service/openaicompat/responses_to_chat.go index b1d0def..1d2d91b 100644 --- a/service/openaicompat/responses_to_chat.go +++ b/service/openaicompat/responses_to_chat.go @@ -4,47 +4,62 @@ import ( "errors" "strings" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" ) +const ( + responsesEventCreated = "response.created" + responsesEventCompleted = "response.completed" + responsesEventDone = "response.done" + responsesEventIncomplete = "response.incomplete" + responsesEventOutputTextDelta = "response.output_text.delta" + responsesEventOutputItemAdded = "response.output_item.added" + responsesEventOutputItemDone = "response.output_item.done" + responsesEventFunctionArgsDelta = "response.function_call_arguments.delta" + responsesEventFunctionArgsDone = "response.function_call_arguments.done" + responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta" + responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done" + responsesOutputTypeFunctionCall = "function_call" + responsesOutputTypeCustomToolCall = "custom_tool_call" + responsesOutputTypeMessage = "message" + responsesOutputTypeReasoning = "reasoning" + responsesIncompleteReasonContentFilter = "content_filter" + responsesIncompleteReasonMaxTokens = "max_output_tokens" +) + +func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) { + if resp == nil { + return "", false + } + if responseStatusString(resp) != "incomplete" { + return "", false + } + reason := "" + if resp.IncompleteDetails != nil { + reason = strings.TrimSpace(resp.IncompleteDetails.Reason) + } + if reason == responsesIncompleteReasonContentFilter { + return "content_filter", true + } + return "length", true +} + func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) { if resp == nil { return nil, nil, errors.New("response is nil") } text := ExtractOutputTextFromResponses(resp) + reasoning := ExtractReasoningTextFromResponses(resp) - usage := &dto.Usage{} - if resp.Usage != nil { - if resp.Usage.InputTokens != 0 { - usage.PromptTokens = resp.Usage.InputTokens - usage.InputTokens = resp.Usage.InputTokens - } - if resp.Usage.OutputTokens != 0 { - usage.CompletionTokens = resp.Usage.OutputTokens - usage.OutputTokens = resp.Usage.OutputTokens - } - if resp.Usage.TotalTokens != 0 { - usage.TotalTokens = resp.Usage.TotalTokens - } else { - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens - } - if resp.Usage.InputTokensDetails != nil { - usage.PromptTokensDetails.CachedTokens = resp.Usage.InputTokensDetails.CachedTokens - usage.PromptTokensDetails.ImageTokens = resp.Usage.InputTokensDetails.ImageTokens - usage.PromptTokensDetails.AudioTokens = resp.Usage.InputTokensDetails.AudioTokens - } - if resp.Usage.CompletionTokenDetails.ReasoningTokens != 0 { - usage.CompletionTokenDetails.ReasoningTokens = resp.Usage.CompletionTokenDetails.ReasoningTokens - } - } - + usage := UsageFromResponsesUsage(resp.Usage) created := resp.CreatedAt var toolCalls []dto.ToolCallResponse - if text == "" && len(resp.Output) > 0 { + if len(resp.Output) > 0 { for _, out := range resp.Output { - if out.Type != "function_call" { + if !isResponsesToolOutputType(out.Type) { continue } name := strings.TrimSpace(out.Name) @@ -67,7 +82,9 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons } finishReason := "stop" - if len(toolCalls) > 0 { + if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok { + finishReason = mappedReason + } else if len(toolCalls) > 0 { finishReason = "tool_calls" } @@ -75,9 +92,11 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons Role: "assistant", Content: text, } + if reasoning != "" { + msg.ReasoningContent = &reasoning + } if len(toolCalls) > 0 { msg.SetToolCalls(toolCalls) - msg.Content = "" } out := &dto.OpenAITextResponse{ @@ -98,6 +117,35 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons return out, usage, nil } +func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage { + usage := &dto.Usage{} + if src == nil { + return usage + } + if src.InputTokens != 0 { + usage.PromptTokens = src.InputTokens + usage.InputTokens = src.InputTokens + } + if src.OutputTokens != 0 { + usage.CompletionTokens = src.OutputTokens + usage.OutputTokens = src.OutputTokens + } + if src.TotalTokens != 0 { + usage.TotalTokens = src.TotalTokens + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if src.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens + usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens + usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens + } + if src.CompletionTokenDetails.ReasoningTokens != 0 { + usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens + } + return usage +} + func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { if resp == nil || len(resp.Output) == 0 { return "" @@ -135,3 +183,34 @@ func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { } return sb.String() } + +func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string { + if resp == nil || len(resp.Output) == 0 { + return "" + } + var sb strings.Builder + for _, out := range resp.Output { + if out.Type != responsesOutputTypeReasoning { + continue + } + for _, c := range out.Content { + if c.Text != "" { + sb.WriteString(c.Text) + } + } + } + return sb.String() +} + +func responseStatusString(resp *dto.OpenAIResponsesResponse) string { + if resp == nil || len(resp.Status) == 0 { + return "" + } + var status string + _ = common.Unmarshal(resp.Status, &status) + return strings.TrimSpace(status) +} + +func isResponsesToolOutputType(outputType string) bool { + return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall +} diff --git a/service/task_billing.go b/service/task_billing.go index ab8e32a..c0d977d 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -187,6 +187,7 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { TokenId: task.PrivateData.TokenId, Group: task.Group, Other: other, + NodeName: task.PrivateData.NodeName, }) } @@ -250,6 +251,7 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int TokenId: task.PrivateData.TokenId, Group: task.Group, Other: other, + NodeName: task.PrivateData.NodeName, }) } diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 3bedb60..a45648c 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -184,6 +184,13 @@ func countLogs(t *testing.T) int64 { return count } +func resetQuotaDataCache(t *testing.T) { + t.Helper() + model.CacheQuotaDataLock.Lock() + defer model.CacheQuotaDataLock.Unlock() + model.CacheQuotaData = make(map[string]*model.QuotaData) +} + // =========================================================================== // RefundTaskQuota tests // =========================================================================== @@ -326,6 +333,52 @@ func TestRecalculate_PositiveDelta(t *testing.T) { assert.Equal(t, actualQuota-preConsumed, log.Quota) } +func TestRecalculate_PositiveDeltaAttributesQuotaDataToSubmitNode(t *testing.T) { + truncate(t) + resetQuotaDataCache(t) + ctx := context.Background() + + originalDataExportEnabled := common.DataExportEnabled + originalNodeName := common.NodeName + common.DataExportEnabled = true + common.NodeName = "polling-node" + t.Cleanup(func() { + common.DataExportEnabled = originalDataExportEnabled + common.NodeName = originalNodeName + resetQuotaDataCache(t) + }) + + const userID, tokenID, channelID = 110, 110, 110 + const initQuota, preConsumed = 10000, 2000 + const actualQuota = 3500 + const tokenRemain = 5000 + + seedUser(t, userID, initQuota) + seedToken(t, tokenID, userID, "sk-recalc-node", tokenRemain) + seedChannel(t, channelID) + + task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) + task.PrivateData.NodeName = "submit-node" + + RecalculateTaskQuota(ctx, task, actualQuota, "node attribution") + + require.Eventually(t, func() bool { + model.CacheQuotaDataLock.Lock() + defer model.CacheQuotaDataLock.Unlock() + for _, quotaData := range model.CacheQuotaData { + if quotaData.UserID == userID && + quotaData.ModelName == "test-model" && + quotaData.Quota == actualQuota-preConsumed && + quotaData.TokenID == tokenID && + quotaData.ChannelID == channelID && + quotaData.UseGroup == "default" { + return quotaData.NodeName == "submit-node" + } + } + return false + }, time.Second, 10*time.Millisecond) +} + func TestRecalculate_NegativeDelta(t *testing.T) { truncate(t) ctx := context.Background() diff --git a/web/default/src/features/channels/lib/advanced-custom.ts b/web/default/src/features/channels/lib/advanced-custom.ts index 0fe48c4..fff5dba 100644 --- a/web/default/src/features/channels/lib/advanced-custom.ts +++ b/web/default/src/features/channels/lib/advanced-custom.ts @@ -43,6 +43,10 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{ value: 'openai_chat_completions_to_openai_responses', label: 'OpenAI Chat to OpenAI Responses', }, + { + value: 'openai_responses_to_openai_chat_completions', + label: 'OpenAI Responses to OpenAI Chat', + }, { value: 'gemini_generate_content_to_openai_chat_completions', label: 'Gemini Generate Content to OpenAI Chat', @@ -326,6 +330,9 @@ export function getAdvancedCustomUpstreamPathPlaceholder( if (converter === 'openai_chat_completions_to_anthropic_messages') { return '/v1/messages' } + if (converter === 'openai_responses_to_openai_chat_completions') { + return '/v1/chat/completions' + } return '/v1/chat/completions' } @@ -575,6 +582,9 @@ function isConverterPathAllowed( ) { return incomingPath === '/v1/chat/completions' } + if (converter === 'openai_responses_to_openai_chat_completions') { + return incomingPath === '/v1/responses' + } return ( incomingPath.includes(':generateContent') || incomingPath.includes(':streamGenerateContent') diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index cbe416c..80757cf 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -146,6 +146,7 @@ export type AdvancedCustomConverter = | 'anthropic_messages_to_openai_chat_completions' | 'openai_chat_completions_to_anthropic_messages' | 'openai_chat_completions_to_openai_responses' + | 'openai_responses_to_openai_chat_completions' | 'gemini_generate_content_to_openai_chat_completions' | 'openai_chat_completions_to_gemini_generate_content' diff --git a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx index 7521138..cefaba3 100644 --- a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx +++ b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx @@ -61,7 +61,7 @@ const route = getRouteApi('/_authenticated/usage-logs/$section') type LogTypeValue = (typeof LOG_TYPE_FILTER_VALUES)[number] function isLogTypeValue(value: string): value is LogTypeValue { - return LOG_TYPE_FILTER_VALUES.includes(value) + return (LOG_TYPE_FILTER_VALUES as readonly string[]).includes(value) } interface CommonLogsFilterBarProps { diff --git a/web/default/src/features/usage-logs/constants.ts b/web/default/src/features/usage-logs/constants.ts index 8b93121..54a8423 100644 --- a/web/default/src/features/usage-logs/constants.ts +++ b/web/default/src/features/usage-logs/constants.ts @@ -67,6 +67,8 @@ export const LOG_TYPE_ENUM = { */ export const LOG_TYPE_ALL_VALUE = '0' as const export const LOG_TYPE_RETRY_VALUE = 'retry' as const +export const LOG_TYPE_ERROR_RETRY_VALUE = 'error_retry' as const +export const LOG_TYPE_EMPTY_RETRY_VALUE = 'empty_retry' as const // ============================================================================ // Time Range Presets @@ -107,7 +109,8 @@ export const LOG_TYPES = [ */ export const LOG_TYPE_FILTERS = [ { label: 'All Types', value: LOG_TYPE_ALL_VALUE }, - { label: 'Retry', value: LOG_TYPE_RETRY_VALUE }, + { label: 'Empty Completion Retry', value: LOG_TYPE_EMPTY_RETRY_VALUE }, + { label: 'Error Retry', value: LOG_TYPE_ERROR_RETRY_VALUE }, ...LOG_TYPES.filter((type) => type.value !== LOG_TYPE_ENUM.UNKNOWN).map( (type) => ({ label: type.label, @@ -116,9 +119,23 @@ export const LOG_TYPE_FILTERS = [ ), ] as const -export const LOG_TYPE_FILTER_VALUES = LOG_TYPE_FILTERS.map( - (type) => type.value -) as [string, ...string[]] +export const LOG_TYPE_FILTER_VALUES = [ + LOG_TYPE_ALL_VALUE, + LOG_TYPE_EMPTY_RETRY_VALUE, + LOG_TYPE_ERROR_RETRY_VALUE, + '1', + '2', + '3', + '4', + '5', + '6', + '7', +] as const + +export const LOG_TYPE_SEARCH_VALUES = [ + ...LOG_TYPE_FILTER_VALUES, + LOG_TYPE_RETRY_VALUE, +] as [string, ...string[]] // ============================================================================ // Drawing Logs (Midjourney) Constants diff --git a/web/default/src/features/usage-logs/lib/utils.test.ts b/web/default/src/features/usage-logs/lib/utils.test.ts index 75385dc..1cdb813 100644 --- a/web/default/src/features/usage-logs/lib/utils.test.ts +++ b/web/default/src/features/usage-logs/lib/utils.test.ts @@ -33,6 +33,26 @@ describe('buildApiParams', () => { assert.equal(params.type, undefined) }) + test('maps retry subtype filters to log_filter', () => { + const emptyParams = buildApiParams({ + page: 1, + pageSize: 100, + searchParams: { type: ['empty_retry'] }, + isAdmin: true, + }) + assert.equal(emptyParams.log_filter, 'empty_retry') + assert.equal(emptyParams.type, undefined) + + const errorParams = buildApiParams({ + page: 1, + pageSize: 100, + searchParams: { type: ['error_retry'] }, + isAdmin: true, + }) + assert.equal(errorParams.log_filter, 'error_retry') + assert.equal(errorParams.type, undefined) + }) + test('treats mixed retry and numeric column filters as retry filters', () => { const params = buildApiParams({ page: 1, @@ -115,6 +135,71 @@ describe('matchesCommonLogTypeFilter', () => { ) }) + test('matches retry subtype filters against row retry markers', () => { + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: '{}', is_error_retry: true }, + ['error_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: JSON.stringify({ retry_log: true }) }, + ['error_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { + type: 2, + other: JSON.stringify({ retry_log: true, empty_retry: true }), + }, + ['error_retry'] + ), + false + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: JSON.stringify({ empty_retry: true }) }, + ['error_retry'] + ), + false + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: '{}', is_empty_retry: true }, + ['empty_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: JSON.stringify({ empty_retry: true }) }, + ['empty_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { + type: 2, + other: JSON.stringify({ retry_log: true, empty_retry: true }), + }, + ['empty_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: JSON.stringify({ retry_log: true }) }, + ['empty_retry'] + ), + false + ) + }) + test('gives retry precedence for mixed retry and numeric filters', () => { assert.equal( matchesCommonLogTypeFilter({ type: 1, other: '{}' }, ['retry', '1']), diff --git a/web/default/src/features/usage-logs/lib/utils.ts b/web/default/src/features/usage-logs/lib/utils.ts index a7e91b3..e575785 100644 --- a/web/default/src/features/usage-logs/lib/utils.ts +++ b/web/default/src/features/usage-logs/lib/utils.ts @@ -33,6 +33,8 @@ import { DISPLAYABLE_LOG_TYPES, TIMING_LOG_TYPES, LOG_TYPE_RETRY_VALUE, + LOG_TYPE_ERROR_RETRY_VALUE, + LOG_TYPE_EMPTY_RETRY_VALUE, } from '../constants' import type { GetLogsParams, @@ -96,7 +98,13 @@ function timestampToSeconds(ms: number): number { } export function matchesCommonLogTypeFilter( - log: { type: number; other: string; is_retry?: boolean }, + log: { + type: number + other: string + is_retry?: boolean + is_error_retry?: boolean + is_empty_retry?: boolean + }, value: unknown ): boolean { if (!Array.isArray(value) || value.length === 0) return true @@ -107,7 +115,23 @@ export function matchesCommonLogTypeFilter( if (filterValues.length === 0) return true if (filterValues.includes(LOG_TYPE_ALL_VALUE)) return true - if (filterValues.includes(LOG_TYPE_RETRY_VALUE)) { + if ( + filterValues.includes(LOG_TYPE_RETRY_VALUE) || + filterValues.includes(LOG_TYPE_ERROR_RETRY_VALUE) || + filterValues.includes(LOG_TYPE_EMPTY_RETRY_VALUE) + ) { + if (filterValues.includes(LOG_TYPE_ERROR_RETRY_VALUE)) { + if (log.is_error_retry === true) return true + if (log.is_empty_retry === true) return false + const other = parseLogOther(log.other) + if (other?.empty_retry === true) return false + return other?.retry_log === true + } + if (filterValues.includes(LOG_TYPE_EMPTY_RETRY_VALUE)) { + if (log.is_empty_retry === true) return true + const other = parseLogOther(log.other) + return other?.empty_retry === true + } if (log.is_retry === true) { return true } @@ -222,8 +246,15 @@ export function buildApiParams(config: { return } - if (typeValues.includes(LOG_TYPE_RETRY_VALUE)) { - params.log_filter = LOG_TYPE_RETRY_VALUE + const retryFilter = typeValues.find((type) => + [ + LOG_TYPE_RETRY_VALUE, + LOG_TYPE_ERROR_RETRY_VALUE, + LOG_TYPE_EMPTY_RETRY_VALUE, + ].includes(type) + ) + if (retryFilter) { + params.log_filter = retryFilter params.type = undefined return } diff --git a/web/default/src/features/usage-logs/types.ts b/web/default/src/features/usage-logs/types.ts index d3187d1..677cf76 100644 --- a/web/default/src/features/usage-logs/types.ts +++ b/web/default/src/features/usage-logs/types.ts @@ -39,6 +39,8 @@ export interface UsageLog { use_time: number is_stream: boolean is_retry?: boolean + is_error_retry?: boolean + is_empty_retry?: boolean channel: number channel_name?: string token_id?: number diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 7f2d304..7283260 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key", "by": "by", "By category": "By category", - "By model author": "By model author", "By clicking sign in, you agree to our": "By clicking sign in, you agree to our", "By creating an account, you agree to our": "By creating an account, you agree to our", + "By model author": "By model author", "Cache": "Cache", "Cache create (1h) price": "Cache create (1h) price", "Cache create price": "Cache create price", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "Email, summarisation, knowledge work", "Embeddings": "Embeddings", "Empty": "Empty", + "Empty Completion Retry": "Empty Completion Retry", "Empty value will be saved as {}.": "Empty value will be saved as {}.", "Enable": "Enable", "Enable 2FA": "Enable 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "Error Message", "Error Message (required)": "Error Message (required)", "Error Message Path": "Error Message Path", + "Error Retry": "Error Retry", "Error Type (optional)": "Error Type (optional)", "Estimated cost": "Estimated cost", "Estimated quota cost": "Estimated quota cost", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "Human-readable name shown to users during Passkey prompts.", "I confirm enabling high-risk retry": "I Confirm Enabling High-risk Retry", "I have read and agree to the": "I have read and agree to the", - "I have read and agree to the User Agreement.": "I have read and agree to the User Agreement.", "I have read and agree to the User Agreement and Privacy Policy.": "I have read and agree to the User Agreement and Privacy Policy.", + "I have read and agree to the User Agreement.": "I have read and agree to the User Agreement.", "I have read and agree to the Privacy Policy.": "I have read and agree to the Privacy Policy.", "I have read and understood the above compliance reminder": "I have read and understood the above compliance reminder", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 7733c1d..177ce64 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB", "by": "par", "By category": "Par catégorie", - "By model author": "Par auteur du modèle", "By clicking sign in, you agree to our": "En cliquant sur Connexion, vous acceptez", "By creating an account, you agree to our": "En créant un compte, vous acceptez", + "By model author": "Par auteur du modèle", "Cache": "Cache", "Cache create (1h) price": "Prix de création du cache (1 h)", "Cache create price": "Prix de création du cache", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "Email, résumé, travail intellectuel", "Embeddings": "Embeddings", "Empty": "Vide", + "Empty Completion Retry": "Relance pour complétion vide", "Empty value will be saved as {}.": "Une valeur vide sera enregistrée comme {}.", "Enable": "Activer", "Enable 2FA": "Activer 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "Message d'erreur", "Error Message (required)": "Message d'erreur (requis)", "Error Message Path": "Chemin du message d’erreur", + "Error Retry": "Relance sur erreur", "Error Type (optional)": "Type d'erreur (optionnel)", "Estimated cost": "Coût estimé", "Estimated quota cost": "Coût de quota estimé", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "Nom lisible par l'homme affiché aux utilisateurs lors des invites de clé d'accès (Passkey).", "I confirm enabling high-risk retry": "Je confirme l'activation de la relance à haut risque", "I have read and agree to the": "J'ai lu et j'accepte les", - "I have read and agree to the User Agreement.": "J'ai lu et j'accepte l'Accord utilisateur.", "I have read and agree to the User Agreement and Privacy Policy.": "J'ai lu et j'accepte l'Accord utilisateur et la Politique de confidentialité.", + "I have read and agree to the User Agreement.": "J'ai lu et j'accepte l'Accord utilisateur.", "I have read and agree to the Privacy Policy.": "J'ai lu et j'accepte la Politique de confidentialité.", "I have read and understood the above compliance reminder": "J’ai lu et compris le rappel de conformité ci-dessus", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "J’ai lu et compris le rappel de conformité ci-dessus, je reconnais les risques juridiques associés et confirme assumer la responsabilité juridique liée au déploiement, à l’exploitation et à la facturation.", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index f1e759a..b6a8a78 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー", "by": "によって", "By category": "カテゴリ別", - "By model author": "モデル提供者別", "By clicking sign in, you agree to our": "ログインすると、以下に同意したものとみなされます", "By creating an account, you agree to our": "アカウントを作成すると、以下に同意したものとみなされます", + "By model author": "モデル提供者別", "Cache": "キャッシュ", "Cache create (1h) price": "キャッシュ作成価格 (1時間)", "Cache create price": "キャッシュ作成価格", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "メール・要約・ナレッジワーク", "Embeddings": "埋め込み", "Empty": "空", + "Empty Completion Retry": "空補完リトライ", "Empty value will be saved as {}.": "空の値は {} として保存されます。", "Enable": "有効にする", "Enable 2FA": "2FA を有効にする", @@ -1689,6 +1690,7 @@ "Error Message": "エラーメッセージ", "Error Message (required)": "エラーメッセージ(必須)", "Error Message Path": "エラーメッセージパス", + "Error Retry": "エラーリトライ", "Error Type (optional)": "エラータイプ(任意)", "Estimated cost": "推定コスト", "Estimated quota cost": "想定クォートコスト", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "パスキーのプロンプト中にユーザーに表示される、人間が読める名前。", "I confirm enabling high-risk retry": "高リスクリトライの有効化を確認します", "I have read and agree to the": "私は以下を読み、同意します", - "I have read and agree to the User Agreement.": "ユーザー利用規約を読み、同意します。", "I have read and agree to the User Agreement and Privacy Policy.": "ユーザー利用規約およびプライバシーポリシーを読み、同意します。", + "I have read and agree to the User Agreement.": "ユーザー利用規約を読み、同意します。", "I have read and agree to the Privacy Policy.": "プライバシーポリシーを読み、同意します。", "I have read and understood the above compliance reminder": "上記のコンプライアンス注意事項を読み、理解しました", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "上記のコンプライアンス注意事項を読み理解し、関連する法的リスクを認識したうえで、デプロイ、運用、課金行為に起因する法的責任を負うことを確認します。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 5135a0e..ddf6013 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности", "by": "от", "By category": "По категориям", - "By model author": "По автору модели", "By clicking sign in, you agree to our": "Нажимая «Войти», вы принимаете", "By creating an account, you agree to our": "Создавая аккаунт, вы принимаете", + "By model author": "По автору модели", "Cache": "Кэш", "Cache create (1h) price": "Цена создания кэша (1 ч)", "Cache create price": "Цена создания кэша", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "Электронная почта, резюме, knowledge work", "Embeddings": "Встраивания", "Empty": "Пусто", + "Empty Completion Retry": "Повтор при пустом завершении", "Empty value will be saved as {}.": "Пустое значение будет сохранено как {}.", "Enable": "Включить", "Enable 2FA": "Включить 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "Сообщение об ошибке", "Error Message (required)": "Сообщение об ошибке (обязательно)", "Error Message Path": "Путь сообщения об ошибке", + "Error Retry": "Повтор при ошибке", "Error Type (optional)": "Тип ошибки (необязательно)", "Estimated cost": "Примерная стоимость", "Estimated quota cost": "Ориентир стоимости квоты", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "Понятное для человека имя, отображаемое пользователям во время запросов Passkey.", "I confirm enabling high-risk retry": "Я подтверждаю включение высокорискового повтора", "I have read and agree to the": "Я прочитал и согласен с", - "I have read and agree to the User Agreement.": "Я прочитал и принимаю Пользовательское соглашение.", "I have read and agree to the User Agreement and Privacy Policy.": "Я прочитал и принимаю Пользовательское соглашение и Политику конфиденциальности.", + "I have read and agree to the User Agreement.": "Я прочитал и принимаю Пользовательское соглашение.", "I have read and agree to the Privacy Policy.": "Я прочитал и принимаю Политику конфиденциальности.", "I have read and understood the above compliance reminder": "Я прочитал и понял приведенное выше напоминание о соответствии", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "Я прочитал и понял приведенное выше напоминание о соответствии, признаю связанные правовые риски и подтверждаю, что несу юридическую ответственность за развертывание, эксплуатацию и взимание платы.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 31827f0..6052d8a 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB", "by": "by", "By category": "Theo danh mục", - "By model author": "Theo nhà phát triển mô hình", "By clicking sign in, you agree to our": "Khi nhấn đăng nhập, bạn đồng ý với", "By creating an account, you agree to our": "Khi tạo tài khoản, bạn đồng ý với", + "By model author": "Theo nhà phát triển mô hình", "Cache": "Bộ nhớ đệm", "Cache create (1h) price": "Giá tạo cache (1 giờ)", "Cache create price": "Giá tạo cache", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "Email, tóm tắt, làm việc tri thức", "Embeddings": "Embeddings", "Empty": "Trống", + "Empty Completion Retry": "Thử lại khi hoàn tất rỗng", "Empty value will be saved as {}.": "Giá trị trống sẽ được lưu thành {}.", "Enable": "Bật", "Enable 2FA": "Bật 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "Thông báo lỗi", "Error Message (required)": "Thông báo lỗi (bắt buộc)", "Error Message Path": "Đường dẫn thông báo lỗi", + "Error Retry": "Thử lại do lỗi", "Error Type (optional)": "Loại lỗi (tùy chọn)", "Estimated cost": "Chi phí ước tính", "Estimated quota cost": "Ước tính chi phí hạn mức", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "Tên dễ đọc hiển thị cho người dùng trong quá trình nhắc nhở Passkey.", "I confirm enabling high-risk retry": "Tôi xác nhận bật thử lại rủi ro cao", "I have read and agree to the": "Tôi đã đọc và đồng ý với", - "I have read and agree to the User Agreement.": "Tôi đã đọc và đồng ý với Thỏa thuận người dùng.", "I have read and agree to the User Agreement and Privacy Policy.": "Tôi đã đọc và đồng ý với Thỏa thuận người dùngChính sách quyền riêng tư.", + "I have read and agree to the User Agreement.": "Tôi đã đọc và đồng ý với Thỏa thuận người dùng.", "I have read and agree to the Privacy Policy.": "Tôi đã đọc và đồng ý với Chính sách quyền riêng tư.", "I have read and understood the above compliance reminder": "Tôi đã đọc và hiểu nhắc nhở tuân thủ ở trên", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "Tôi đã đọc và hiểu nhắc nhở tuân thủ ở trên, thừa nhận các rủi ro pháp lý liên quan và xác nhận rằng tôi chịu trách nhiệm pháp lý phát sinh từ việc triển khai, vận hành và thu phí.", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 7aca7bd..e333e1e 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥", "by": "由", "By category": "按行业", - "By model author": "按模型厂商", "By clicking sign in, you agree to our": "点击登录即表示您同意我们的", "By creating an account, you agree to our": "创建账户即表示您同意我们的", + "By model author": "按模型厂商", "Cache": "缓存", "Cache create (1h) price": "1 小时缓存写入价格", "Cache create price": "缓存写入价格", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "邮件、摘要与知识工作", "Embeddings": "嵌入", "Empty": "空", + "Empty Completion Retry": "空补全重试", "Empty value will be saved as {}.": "空值将保存为 {}。", "Enable": "启用", "Enable 2FA": "启用 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "错误消息", "Error Message (required)": "错误消息(必填)", "Error Message Path": "错误消息路径", + "Error Retry": "错误重试", "Error Type (optional)": "错误类型(可选)", "Estimated cost": "预计成本", "Estimated quota cost": "估算配额费用", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "在 Passkey 提示期间向用户显示的人类可读名称。", "I confirm enabling high-risk retry": "我确认开启高危重试", "I have read and agree to the": "我已阅读并同意", - "I have read and agree to the User Agreement.": "我已阅读并同意用户协议。", "I have read and agree to the User Agreement and Privacy Policy.": "我已阅读并同意用户协议隐私政策。", + "I have read and agree to the User Agreement.": "我已阅读并同意用户协议。", "I have read and agree to the Privacy Policy.": "我已阅读并同意隐私政策。", "I have read and understood the above compliance reminder": "我已阅读并理解上述合规提醒", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "我已阅读并理解上述合规提醒,确认相关法律风险,并确认承担因部署、运营和收费行为产生的法律责任。", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 753e6bd..b150714 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -51,6 +51,8 @@ export const STATIC_I18N_KEYS = [ 'Token-based', 'Per Request', 'All Types', + 'Empty Completion Retry', + 'Error Retry', 'Chat', 'Response', 'Anthropic', diff --git a/web/default/src/routes/_authenticated/usage-logs/$section.tsx b/web/default/src/routes/_authenticated/usage-logs/$section.tsx index 1542565..726671c 100644 --- a/web/default/src/routes/_authenticated/usage-logs/$section.tsx +++ b/web/default/src/routes/_authenticated/usage-logs/$section.tsx @@ -19,7 +19,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import z from 'zod' import { createFileRoute, redirect } from '@tanstack/react-router' import { UsageLogs } from '@/features/usage-logs' -import { LOG_TYPE_FILTER_VALUES } from '@/features/usage-logs/constants' +import { LOG_TYPE_SEARCH_VALUES } from '@/features/usage-logs/constants' import { isUsageLogsSectionId, USAGE_LOGS_DEFAULT_SECTION, @@ -31,7 +31,7 @@ const logTypeSearchSchema = z if (value == null || value === '') return undefined return Array.isArray(value) ? value : [value] }, - z.array(z.enum(LOG_TYPE_FILTER_VALUES)).optional() + z.array(z.enum(LOG_TYPE_SEARCH_VALUES)).optional() ) .catch([]) From fc3dcebe0e60558b630a5a8c684d69cb81b23d43 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 2 Jul 2026 01:11:47 +0800 Subject: [PATCH 2/8] v1.0.4-preview.1 --- .../src/features/usage-logs/lib/utils.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/web/default/src/features/usage-logs/lib/utils.ts b/web/default/src/features/usage-logs/lib/utils.ts index e575785..4ea1f41 100644 --- a/web/default/src/features/usage-logs/lib/utils.ts +++ b/web/default/src/features/usage-logs/lib/utils.ts @@ -45,6 +45,18 @@ import type { } from '../types' import { parseLogOther } from './format' +const RETRY_LOG_FILTER_VALUES = [ + LOG_TYPE_RETRY_VALUE, + LOG_TYPE_ERROR_RETRY_VALUE, + LOG_TYPE_EMPTY_RETRY_VALUE, +] as const + +type RetryLogFilterValue = (typeof RETRY_LOG_FILTER_VALUES)[number] + +function isRetryLogFilterValue(value: string): value is RetryLogFilterValue { + return (RETRY_LOG_FILTER_VALUES as readonly string[]).includes(value) +} + // ============================================================================ // Type Checkers & Utilities // ============================================================================ @@ -246,13 +258,7 @@ export function buildApiParams(config: { return } - const retryFilter = typeValues.find((type) => - [ - LOG_TYPE_RETRY_VALUE, - LOG_TYPE_ERROR_RETRY_VALUE, - LOG_TYPE_EMPTY_RETRY_VALUE, - ].includes(type) - ) + const retryFilter = typeValues.find(isRetryLogFilterValue) if (retryFilter) { params.log_filter = retryFilter params.type = undefined From 3abcc4f50bf4ea8f24b4d9c98a9c411213f2dc29 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 2 Jul 2026 01:13:19 +0800 Subject: [PATCH 3/8] v1.0.4-preview.1 --- tools/jsonwrapcheck/allowlist.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/jsonwrapcheck/allowlist.txt b/tools/jsonwrapcheck/allowlist.txt index 60e23df..10eb2a6 100644 --- a/tools/jsonwrapcheck/allowlist.txt +++ b/tools/jsonwrapcheck/allowlist.txt @@ -110,7 +110,6 @@ relay/channel/baidu/relay-baidu.go|baiduHandler|Marshal|92981973316a59184c1f9d4f relay/channel/baidu/relay-baidu.go|baiduEmbeddingHandler|Unmarshal|6c88b37cda6b69cb880852f57074d792626b82bd771acbab3a46754f80631684 relay/channel/baidu/relay-baidu.go|baiduEmbeddingHandler|Marshal|92981973316a59184c1f9d4fdca6fe3a1045b5da6a30f89abb52b516f1849571 relay/channel/baidu/relay-baidu.go|getBaiduAccessTokenHelper|NewDecoder|64ee82d1a886a8c84d5771d6a7733571f892d8b735ff5b5ffa0167dc5f1ecb39 -relay/channel/claude/relay-claude.go|RequestOpenAI2ClaudeMessage|Unmarshal|c2fc6982d143a7151516c79cbfae04ae1c89be1758026d0b9461014206dbed15 relay/channel/claude/relay-claude.go|ResponseClaude2OpenAI|Marshal|9f23cc0eecff96adc0410bf3360508a44815f9bc18567a76278a14aaa6e46e0a relay/channel/claude/relay-claude.go|HandleClaudeResponseData|Marshal|0e01a2a781af1a773195f919e8bc2c53345aed7dd99776d98cfe550576ea831a relay/channel/cloudflare/relay_cloudflare.go|cfStreamHandler|Unmarshal|5a9c4fba44410fd4d5ee918ce49cdd49a713d756c491be89ccc2d9f3de44cdc5 From 4e24e6732ce301c395b4c8f89e9c2b60a048dd08 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 2 Jul 2026 09:27:50 +0800 Subject: [PATCH 4/8] v1.0.4-preview.1 --- model/log.go | 2 +- model/log_test.go | 12 +-- relay/channel/gemini/adaptor_responses.go | 6 +- .../channel/gemini/adaptor_responses_test.go | 43 +++++++-- relay/channel/task/doubao/adaptor.go | 8 +- .../doubao/adaptor_media_protocol_test.go | 29 +++++- service/convert.go | 40 +++----- service/convert_test.go | 13 +++ .../chat_to_responses_response.go | 12 +-- service/openaicompat/responses_compat_test.go | 93 +++++++++++++++++++ .../openaicompat/responses_request_to_chat.go | 15 +-- service/openaicompat/responses_to_chat.go | 6 +- 12 files changed, 215 insertions(+), 64 deletions(-) diff --git a/model/log.go b/model/log.go index ea43ebb..3419ba0 100644 --- a/model/log.go +++ b/model/log.go @@ -247,7 +247,7 @@ func logOtherRetryMarkers(other string) (errorRetry bool, emptyRetry bool) { return false, false } if marker, ok := otherMap["empty_retry"].(bool); ok && marker { - return false, true + emptyRetry = true } if retryLog, ok := otherMap["retry_log"].(bool); ok && retryLog { errorRetry = true diff --git a/model/log_test.go b/model/log_test.go index 9554906..3100dc8 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -84,9 +84,9 @@ func TestGetAllLogsRetrySubtypeFilters(t *testing.T) { errorLogs, total, err := GetAllLogs(LogTypeUnknown, LogFilterErrorRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") require.NoError(t, err) - require.EqualValues(t, 3, total) - require.Len(t, errorLogs, 3) - require.ElementsMatch(t, []int{logs[0].Id, logs[4].Id, logs[5].Id}, []int{errorLogs[0].Id, errorLogs[1].Id, errorLogs[2].Id}) + require.EqualValues(t, 4, total) + require.Len(t, errorLogs, 4) + require.ElementsMatch(t, []int{logs[0].Id, logs[4].Id, logs[5].Id, logs[6].Id}, []int{errorLogs[0].Id, errorLogs[1].Id, errorLogs[2].Id, errorLogs[3].Id}) emptyLogs, total, err := GetAllLogs(LogTypeUnknown, LogFilterEmptyRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") require.NoError(t, err) @@ -105,9 +105,9 @@ func TestSumUsedQuotaRetrySubtypeFilters(t *testing.T) { errorStat, err := SumUsedQuota(LogTypeUnknown, LogFilterErrorRetry, 0, 0, "", "", "", 0, "") require.NoError(t, err) - require.Equal(t, 1000, errorStat.Quota) - require.Equal(t, 3, errorStat.Rpm) - require.Equal(t, 56, errorStat.Tpm) + require.Equal(t, 1350, errorStat.Quota) + require.Equal(t, 4, errorStat.Rpm) + require.Equal(t, 81, errorStat.Tpm) emptyStat, err := SumUsedQuota(LogTypeUnknown, LogFilterEmptyRetry, 0, 0, "", "", "", 0, "") require.NoError(t, err) diff --git a/relay/channel/gemini/adaptor_responses.go b/relay/channel/gemini/adaptor_responses.go index bd41079..87c3cb5 100644 --- a/relay/channel/gemini/adaptor_responses.go +++ b/relay/channel/gemini/adaptor_responses.go @@ -1,6 +1,7 @@ package gemini import ( + "fmt" "strings" "github.com/MAX-API-Next/MAX-API/common" @@ -41,8 +42,9 @@ func filterGeminiResponsesTools(raw []byte) ([]byte, error) { filtered := make([]map[string]any, 0, len(tools)) for _, tool := range tools { - if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" { - continue + toolType := strings.TrimSpace(common.Interface2String(tool["type"])) + if toolType != "function" { + return nil, fmt.Errorf("gemini responses conversion does not support tool type %q", toolType) } filtered = append(filtered, tool) } diff --git a/relay/channel/gemini/adaptor_responses_test.go b/relay/channel/gemini/adaptor_responses_test.go index c533f1c..73dd264 100644 --- a/relay/channel/gemini/adaptor_responses_test.go +++ b/relay/channel/gemini/adaptor_responses_test.go @@ -1,6 +1,7 @@ package gemini import ( + "fmt" "testing" "github.com/MAX-API-Next/MAX-API/common" @@ -58,12 +59,18 @@ func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) }, }), Tools: mustGeminiRawMessage(t, []map[string]any{ - {"type": "custom", "name": "apply_patch"}, - {"type": "unknown", "name": "unknown"}, + { + "type": "function", + "name": "lookup", + "description": "Lookup data", + "parameters": map[string]any{ + "type": "object", + }, + }, }), }) - assert.Empty(t, got.GetTools()) + require.Len(t, got.GetTools(), 1) require.Len(t, got.Contents, 2) assert.Equal(t, "model", got.Contents[0].Role) require.Len(t, got.Contents[0].Parts, 1) @@ -76,8 +83,28 @@ func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) assert.Nil(t, got.Contents[1].Parts[0].FunctionResponse) } +func TestConvertOpenAIResponsesRequestToGeminiRejectsUnsupportedTools(t *testing.T) { + got, err := convertResponsesToGemini(dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Input: mustGeminiRawMessage(t, "hello"), + Tools: mustGeminiRawMessage(t, []map[string]any{ + {"type": "web_search_preview"}, + }), + }) + + require.Error(t, err) + require.Nil(t, got) + assert.Contains(t, err.Error(), `tool type "web_search_preview"`) +} + func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) *dto.GeminiChatRequest { t.Helper() + got, err := convertResponsesToGemini(req) + require.NoError(t, err) + return got +} + +func convertResponsesToGemini(req dto.OpenAIResponsesRequest) (*dto.GeminiChatRequest, error) { info := &relaycommon.RelayInfo{ OriginModelName: req.Model, ChannelMeta: &relaycommon.ChannelMeta{ @@ -85,10 +112,14 @@ func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) }, } got, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, req) - require.NoError(t, err) + if err != nil { + return nil, err + } geminiReq, ok := got.(*dto.GeminiChatRequest) - require.True(t, ok) - return geminiReq + if !ok { + return nil, fmt.Errorf("unexpected converted request type %T", got) + } + return geminiReq, nil } func mustGeminiRawMessage(t *testing.T, value any) []byte { diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index bb9cf8f..5743f4f 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -55,8 +55,8 @@ type requestPayload struct { } `json:"tools,omitempty"` SafetyIdentifier *string `json:"safety_identifier,omitempty"` Priority *dto.IntValue `json:"priority,omitempty"` - Resolution string `json:"resolution,omitempty"` - Ratio string `json:"ratio,omitempty"` + Resolution *string `json:"resolution,omitempty"` + Ratio *string `json:"ratio,omitempty"` Duration *dto.IntValue `json:"duration,omitempty"` Frames *dto.IntValue `json:"frames,omitempty"` Seed *dto.IntValue `json:"seed,omitempty"` @@ -173,8 +173,8 @@ func (a *TaskAdaptor) resolveSeedanceBillingInputs(c *gin.Context, req *relaycom if !a.useSeedanceMediaProtocol() { payload, err := a.convertToRequestPayload(req) if err == nil && payload != nil { - if payload.Resolution != "" { - resolution = payload.Resolution + if payload.Resolution != nil && *payload.Resolution != "" { + resolution = *payload.Resolution } hasVideo = hasVideo || hasVideoInContent(payload.Content) } diff --git a/relay/channel/task/doubao/adaptor_media_protocol_test.go b/relay/channel/task/doubao/adaptor_media_protocol_test.go index 85e6b54..5119e5e 100644 --- a/relay/channel/task/doubao/adaptor_media_protocol_test.go +++ b/relay/channel/task/doubao/adaptor_media_protocol_test.go @@ -267,5 +267,32 @@ func TestConvertToRequestPayloadPreservesSeedanceFields(t *testing.T) { assert.Equal(t, "safety-123", *payload.SafetyIdentifier) require.NotNil(t, payload.Priority) assert.Equal(t, 0, int(*payload.Priority)) - assert.Equal(t, "4k", payload.Resolution) + require.NotNil(t, payload.Resolution) + assert.Equal(t, "4k", *payload.Resolution) +} + +func TestConvertToRequestPayloadPreservesExplicitEmptyOptionalStrings(t *testing.T) { + var req relaycommon.TaskSubmitReq + err := common.Unmarshal([]byte(`{ + "model": "doubao-seedance-2-0-260128", + "prompt": "test", + "metadata": { + "resolution": "", + "ratio": "" + } + }`), &req) + require.NoError(t, err) + + payload, err := (&TaskAdaptor{}).convertToRequestPayload(&req) + require.NoError(t, err) + require.NotNil(t, payload) + require.NotNil(t, payload.Resolution) + assert.Equal(t, "", *payload.Resolution) + require.NotNil(t, payload.Ratio) + assert.Equal(t, "", *payload.Ratio) + + data, err := common.Marshal(payload) + require.NoError(t, err) + assert.Contains(t, string(data), `"resolution":""`) + assert.Contains(t, string(data), `"ratio":""`) } diff --git a/service/convert.go b/service/convert.go index 84cd59d..d6d23e1 100644 --- a/service/convert.go +++ b/service/convert.go @@ -626,12 +626,7 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco claudeContent.Type = "tool_use" claudeContent.Id = toolUse.ID claudeContent.Name = toolUse.Function.Name - var mapParams map[string]interface{} - if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil { - claudeContent.Input = mapParams - } else { - claudeContent.Input = map[string]interface{}{"arguments": toolUse.Function.Arguments} - } + claudeContent.Input = parseToolCallArguments(toolUse.Function.Arguments) contents = append(contents, claudeContent) } } @@ -642,6 +637,17 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco return claudeResponse } +func parseToolCallArguments(rawArgs string) map[string]interface{} { + if rawArgs == "" { + return make(map[string]interface{}) + } + var args map[string]interface{} + if err := common.Unmarshal([]byte(rawArgs), &args); err != nil { + return map[string]interface{}{"arguments": rawArgs} + } + return args +} + func stopReasonOpenAI2Claude(reason string) string { return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason) } @@ -873,19 +879,10 @@ func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relayco if len(toolCalls) > 0 { for _, toolCall := range toolCalls { // 解析参数 - var args map[string]interface{} - if toolCall.Function.Arguments != "" { - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { - args = map[string]interface{}{"arguments": toolCall.Function.Arguments} - } - } else { - args = make(map[string]interface{}) - } - part := dto.GeminiPart{ FunctionCall: &dto.FunctionCall{ FunctionName: toolCall.Function.Name, - Arguments: args, + Arguments: parseToolCallArguments(toolCall.Function.Arguments), }, } content.Parts = append(content.Parts, part) @@ -967,19 +964,10 @@ func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamRespon if choice.Delta.ToolCalls != nil { for _, toolCall := range choice.Delta.ToolCalls { // 解析参数 - var args map[string]interface{} - if toolCall.Function.Arguments != "" { - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { - args = map[string]interface{}{"arguments": toolCall.Function.Arguments} - } - } else { - args = make(map[string]interface{}) - } - part := dto.GeminiPart{ FunctionCall: &dto.FunctionCall{ FunctionName: toolCall.Function.Name, - Arguments: args, + Arguments: parseToolCallArguments(toolCall.Function.Arguments), }, } content.Parts = append(content.Parts, part) diff --git a/service/convert_test.go b/service/convert_test.go index 8fbcbb0..a8c7db3 100644 --- a/service/convert_test.go +++ b/service/convert_test.go @@ -50,6 +50,19 @@ func TestResponseOpenAI2GeminiPreservesTextWithToolCalls(t *testing.T) { assert.Equal(t, map[string]interface{}{"q": "max-api"}, parts[1].FunctionCall.Arguments) } +func TestResponseOpenAI2GeminiWrapsMalformedToolArguments(t *testing.T) { + resp := openAIResponseWithToolCallArguments(t, "{") + resp.Choices[0].Message.Content = "" + got := ResponseOpenAI2Gemini(resp, &relaycommon.RelayInfo{}) + + require.NotNil(t, got) + require.Len(t, got.Candidates, 1) + parts := got.Candidates[0].Content.Parts + require.Len(t, parts, 1) + require.NotNil(t, parts[0].FunctionCall) + assert.Equal(t, map[string]interface{}{"arguments": "{"}, parts[0].FunctionCall.Arguments) +} + func openAIResponseWithTextAndToolCall(t *testing.T) *dto.OpenAITextResponse { return openAIResponseWithToolCallArguments(t, `{"q":"max-api"}`) } diff --git a/service/openaicompat/chat_to_responses_response.go b/service/openaicompat/chat_to_responses_response.go index 71dda1a..b461c25 100644 --- a/service/openaicompat/chat_to_responses_response.go +++ b/service/openaicompat/chat_to_responses_response.go @@ -109,11 +109,7 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id }) } for i, toolCall := range choice.Message.ParseToolCalls() { - toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, status) - if err != nil { - return nil, nil, err - } - out.Output = append(out.Output, toolOutput) + out.Output = append(out.Output, chatToolCallToResponsesOutput(toolCall, id, i, status)) } return out, usage, nil @@ -532,7 +528,7 @@ func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string { return "incomplete" } -func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) { +func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) dto.ResponsesOutput { callID := strings.TrimSpace(toolCall.ID) if callID == "" { callID = fmt.Sprintf("%s_call_%d", responseID, index) @@ -545,7 +541,7 @@ func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID stri CallId: callID, Name: toolCall.Function.Name, Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments), - }, nil + } } return dto.ResponsesOutput{ Type: toolCall.Type, @@ -553,7 +549,7 @@ func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID stri Status: status, CallId: callID, Arguments: toolCall.Custom, - }, nil + } } func chatArgumentsRawMessage(arguments string) []byte { diff --git a/service/openaicompat/responses_compat_test.go b/service/openaicompat/responses_compat_test.go index b6dc3f8..92043a5 100644 --- a/service/openaicompat/responses_compat_test.go +++ b/service/openaicompat/responses_compat_test.go @@ -188,6 +188,47 @@ func TestResponsesRequestToChatCompletionsRequestRejectsCustomToolCallItems(t *t assert.Contains(t, err.Error(), "custom_tool_call") } +func TestResponsesRequestToChatCompletionsRequestRejectsMissingToolCallIDs(t *testing.T) { + cases := []struct { + name string + item map[string]any + want string + }{ + { + name: "function call", + item: map[string]any{ + "type": "function_call", + "name": "lookup", + "arguments": map[string]any{"q": "x"}, + }, + want: "function_call item is missing call_id", + }, + { + name: "function call output", + item: map[string]any{ + "type": "function_call_output", + "output": map[string]any{"ok": true}, + }, + want: "function_call_output item is missing call_id", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, []map[string]any{ + tc.item, + }), + }) + + require.Error(t, err) + require.Nil(t, got) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + func TestResponsesRequestToChatCompletionsRequestRejectsUnsupportedToolTypes(t *testing.T) { got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ Model: "gpt-test", @@ -235,6 +276,58 @@ func TestChatCompletionsResponseToResponsesResponsePreservesToolCallsAndIncomple assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments)) } +func TestResponsesResponseToChatCompletionsResponsePreservesUsageDetails(t *testing.T) { + resp, usage, err := ResponsesResponseToChatCompletionsResponse(&dto.OpenAIResponsesResponse{ + ID: "resp_1", + Model: "gpt-test", + CreatedAt: 456, + Status: []byte(`"completed"`), + Usage: &dto.Usage{ + InputTokens: 7, + OutputTokens: 11, + TotalTokens: 18, + InputTokensDetails: &dto.InputTokenDetails{ + CachedTokens: 1, + CachedCreationTokens: 2, + TextTokens: 3, + AudioTokens: 4, + ImageTokens: 5, + }, + CompletionTokenDetails: dto.OutputTokenDetails{ + ReasoningTokens: 6, + TextTokens: 7, + AudioTokens: 8, + ImageTokens: 9, + }, + }, + Output: []dto.ResponsesOutput{{ + Type: responsesOutputTypeMessage, + Role: "assistant", + Content: []dto.ResponsesOutputContent{{ + Type: "output_text", + Text: "done", + }}, + }}, + }, "chatcmpl_1") + require.NoError(t, err) + require.NotNil(t, usage) + + assert.Equal(t, 7, usage.PromptTokens) + assert.Equal(t, 11, usage.CompletionTokens) + assert.Equal(t, 18, usage.TotalTokens) + assert.Equal(t, 1, usage.PromptTokensDetails.CachedTokens) + assert.Equal(t, 2, usage.PromptTokensDetails.CachedCreationTokens) + assert.Equal(t, 3, usage.PromptTokensDetails.TextTokens) + assert.Equal(t, 4, usage.PromptTokensDetails.AudioTokens) + assert.Equal(t, 5, usage.PromptTokensDetails.ImageTokens) + assert.Equal(t, 6, usage.CompletionTokenDetails.ReasoningTokens) + assert.Equal(t, 7, usage.CompletionTokenDetails.TextTokens) + assert.Equal(t, 8, usage.CompletionTokenDetails.AudioTokens) + assert.Equal(t, 9, usage.CompletionTokenDetails.ImageTokens) + assert.Equal(t, usage.PromptTokensDetails, resp.Usage.PromptTokensDetails) + assert.Equal(t, usage.CompletionTokenDetails, resp.Usage.CompletionTokenDetails) +} + func TestChatCompletionsStreamToResponsesEventsAggregatesUsage(t *testing.T) { state := NewChatToResponsesStreamState("resp_1", "gpt-test") state.Created = 123 diff --git a/service/openaicompat/responses_request_to_chat.go b/service/openaicompat/responses_request_to_chat.go index 19fc53c..bb74f88 100644 --- a/service/openaicompat/responses_request_to_chat.go +++ b/service/openaicompat/responses_request_to_chat.go @@ -159,6 +159,9 @@ func responsesInputItemToChatMessages(item map[string]any, messages []dto.Messag return nil, errors.New("responses to chat conversion does not support custom_tool_call items") case responsesInputTypeFunctionCallOutput: callID := strings.TrimSpace(common.Interface2String(item["call_id"])) + if callID == "" { + return nil, errors.New("function_call_output item is missing call_id") + } content := responseToolOutputToChatContent(item["output"]) return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil } @@ -200,12 +203,6 @@ func responsesInputContentToChatContent(content any) (any, error) { return value, nil case []any: return responsesContentPartsToChatContent(value) - case []map[string]any: - parts := make([]any, 0, len(value)) - for _, part := range value { - parts = append(parts, part) - } - return responsesContentPartsToChatContent(parts) default: return content, nil } @@ -257,8 +254,12 @@ func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallR if name == "" { return dto.ToolCallRequest{}, errors.New("function_call item is missing name") } + callID := responsesCallID(item) + if callID == "" { + return dto.ToolCallRequest{}, errors.New("function_call item is missing call_id") + } return dto.ToolCallRequest{ - ID: responsesCallID(item), + ID: callID, Type: "function", Function: dto.FunctionRequest{ Name: name, diff --git a/service/openaicompat/responses_to_chat.go b/service/openaicompat/responses_to_chat.go index 1d2d91b..8d18f66 100644 --- a/service/openaicompat/responses_to_chat.go +++ b/service/openaicompat/responses_to_chat.go @@ -139,10 +139,10 @@ func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage { usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens + usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens + usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens } - if src.CompletionTokenDetails.ReasoningTokens != 0 { - usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens - } + usage.CompletionTokenDetails = src.CompletionTokenDetails return usage } From 1e7e2f16b96282ec1f6160d108fdec222fd1c7c3 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 2 Jul 2026 09:43:18 +0800 Subject: [PATCH 5/8] v1.0.4-preview.1 --- tools/jsonwrapcheck/allowlist.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/jsonwrapcheck/allowlist.txt b/tools/jsonwrapcheck/allowlist.txt index 10eb2a6..0935833 100644 --- a/tools/jsonwrapcheck/allowlist.txt +++ b/tools/jsonwrapcheck/allowlist.txt @@ -206,8 +206,6 @@ service/codex_oauth.go|decodeJWTClaims|Unmarshal|7ca24118dbcd41c0feb7b0767369b64 service/convert.go|ClaudeToOpenAIRequest|Marshal|ce446f5ba8d179b35eb4bbf8e48f810b753e7a760f3a69cc0f0435092cb0145b service/convert.go|ClaudeToOpenAIRequest|Marshal|3b663c5e2add74ef56c761edc9c50ceb26e287ec2242e79fbf6a9bd35cbc71c6 service/convert.go|toJSONString|Marshal|be383ea6cfaaf292483613ff481847bcf18cbddf173a0870a25e345b29d9f5eb -service/convert.go|ResponseOpenAI2Gemini|Unmarshal|d40a16081c20e0cd2af17a2eac045b9d88afcdcc0fe4179b779269d16e54d731 -service/convert.go|StreamResponseOpenAI2Gemini|Unmarshal|d40a16081c20e0cd2af17a2eac045b9d88afcdcc0fe4179b779269d16e54d731 service/midjourney.go|DoMidjourneyHttpRequest|NewDecoder|dc2d92e0dd4c66591ccceefe086dca58c0af182c9e2c61f92c80cda6833d2f9a service/midjourney.go|DoMidjourneyHttpRequest|Marshal|af957403b76886439ffd61be2eec9cef5f0fd1f3c0b8201b7a5e9565234b44ce service/midjourney.go|DoMidjourneyHttpRequest|Unmarshal|a15ce743bd5d73944713700514fe95dfe90f903ea83e6508c1d05f916dae3d35 From f463b51f59649b7577a3685c5699348aed8cf450 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 3 Jul 2026 19:14:38 +0800 Subject: [PATCH 6/8] v1.0.4-preview.1 --- dto/channel_settings.go | 23 +- model/log.go | 7 - model/log_test.go | 35 ++ relay/channel/api_request.go | 49 +- relay/channel/api_request_test.go | 62 ++ relay/channel/openai/chat_via_responses.go | 23 +- relay/channel/openai/empty_completion_test.go | 44 ++ relay/channel/openai/responses_via_chat.go | 15 +- relay/channel/task/doubao/adaptor.go | 139 ++-- ...otocol_test.go => adaptor_billing_test.go} | 171 ++--- relay/channel/task/doubao/media_protocol.go | 591 ------------------ relay/channel/task/taskcommon/path_config.go | 49 +- .../task/taskcommon/path_config_test.go | 379 +++++++++++ .../task/taskcommon/protocol_config.go | 91 ++- relay/channel/task/taskcommon/request_body.go | 581 +++++++++++++++++ relay/relay_task.go | 18 +- .../drawers/channel-mutate-drawer.tsx | 106 ++++ .../src/features/channels/lib/channel-form.ts | 178 +++++- web/default/src/features/channels/types.ts | 7 + web/default/src/i18n/locales/en.json | 11 + web/default/src/i18n/locales/fr.json | 11 + web/default/src/i18n/locales/ja.json | 11 + web/default/src/i18n/locales/ru.json | 11 + web/default/src/i18n/locales/vi.json | 11 + web/default/src/i18n/locales/zh.json | 11 + web/default/tests/channel-form.test.ts | 230 +++++++ 26 files changed, 2060 insertions(+), 804 deletions(-) rename relay/channel/task/doubao/{adaptor_media_protocol_test.go => adaptor_billing_test.go} (68%) delete mode 100644 relay/channel/task/doubao/media_protocol.go create mode 100644 relay/channel/task/taskcommon/request_body.go create mode 100644 web/default/tests/channel-form.test.ts diff --git a/dto/channel_settings.go b/dto/channel_settings.go index e549d7a..f9c280b 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -53,16 +53,19 @@ type ChannelOtherSettings struct { } type TaskProtocolConfig struct { - SubmitPath string `json:"submit_path,omitempty"` - QueryPath string `json:"query_path,omitempty"` - TaskIDPath string `json:"task_id_path,omitempty"` - StatusPath string `json:"status_path,omitempty"` - ProgressPath string `json:"progress_path,omitempty"` - ResultURLPaths []string `json:"result_url_paths,omitempty"` - ErrorMessagePath string `json:"error_message_path,omitempty"` - CreatedAtPath string `json:"created_at_path,omitempty"` - UpdatedAtPath string `json:"updated_at_path,omitempty"` - StatusMap map[string]string `json:"status_map,omitempty"` + SubmitPath string `json:"submit_path,omitempty"` + QueryPath string `json:"query_path,omitempty"` + RequestBodyMode string `json:"request_body_mode,omitempty"` + RequestBodyMapping map[string]string `json:"request_body_mapping,omitempty"` + RequestBodyDefaults map[string]any `json:"request_body_defaults,omitempty"` + TaskIDPath string `json:"task_id_path,omitempty"` + StatusPath string `json:"status_path,omitempty"` + ProgressPath string `json:"progress_path,omitempty"` + ResultURLPaths []string `json:"result_url_paths,omitempty"` + ErrorMessagePath string `json:"error_message_path,omitempty"` + CreatedAtPath string `json:"created_at_path,omitempty"` + UpdatedAtPath string `json:"updated_at_path,omitempty"` + StatusMap map[string]string `json:"status_map,omitempty"` } func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { diff --git a/model/log.go b/model/log.go index 3419ba0..36ec113 100644 --- a/model/log.go +++ b/model/log.go @@ -59,11 +59,6 @@ type Log struct { LogId int `json:"log_id,omitempty" gorm:"-"` } -func (log *Log) BeforeCreate(*gorm.DB) error { - log.syncRetryMarker() - return nil -} - func (log *Log) BeforeSave(*gorm.DB) error { log.syncRetryMarker() return nil @@ -808,11 +803,9 @@ func SumUsedQuota(logType int, logFilter string, startTimestamp int64, endTimest } if startTimestamp != 0 { tx = tx.Where("created_at >= ?", startTimestamp) - rpmTpmQuery = rpmTpmQuery.Where("created_at >= ?", startTimestamp) } if endTimestamp != 0 { tx = tx.Where("created_at <= ?", endTimestamp) - rpmTpmQuery = rpmTpmQuery.Where("created_at <= ?", endTimestamp) } if tx, err = applyExplicitLogTextFilter(tx, "model_name", modelName); err != nil { return stat, err diff --git a/model/log_test.go b/model/log_test.go index 3100dc8..70efa4a 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -131,6 +131,41 @@ func TestSumUsedQuotaAppliesExplicitLogType(t *testing.T) { require.Equal(t, 0, stat.Tpm) } +func TestSumUsedQuotaKeepsRpmTpmLiveForHistoricalWindow(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + now := time.Now().Unix() + logs := []Log{ + { + UserId: 1, + CreatedAt: now - 10, + Type: LogTypeConsume, + Quota: 100, + PromptTokens: 3, + CompletionTokens: 7, + }, + { + UserId: 1, + CreatedAt: now - 86400, + Type: LogTypeConsume, + Quota: 200, + PromptTokens: 11, + CompletionTokens: 13, + }, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + + stat, err := SumUsedQuota(LogTypeUnknown, "", now-90000, now-80000, "", "", "", 0, "") + + require.NoError(t, err) + require.Equal(t, 200, stat.Quota) + require.Equal(t, 1, stat.Rpm) + require.Equal(t, 10, stat.Tpm) +} + func TestRetryFilterIgnoresNestedRetryMarker(t *testing.T) { require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) t.Cleanup(func() { diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 93c8856..52b6064 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -532,19 +532,58 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http return resp, nil } +func newTaskHTTPRequest(method string, fullRequestURL string, requestBody io.Reader, info *common.RelayInfo) (*http.Request, error) { + req, err := http.NewRequest(method, fullRequestURL, requestBody) + if err != nil { + return nil, err + } + applyUpstreamContentLength(req, info) + if req.GetBody == nil { + attachSeekableGetBody(req, requestBody) + } + return req, nil +} + +func attachSeekableGetBody(req *http.Request, reader io.Reader) { + if req == nil { + return + } + seeker, ok := reader.(io.ReadSeeker) + if !ok { + return + } + start, err := seeker.Seek(0, io.SeekCurrent) + if err != nil { + return + } + end, err := seeker.Seek(0, io.SeekEnd) + if err != nil { + _, _ = seeker.Seek(start, io.SeekStart) + return + } + if _, err := seeker.Seek(start, io.SeekStart); err != nil { + return + } + if req.ContentLength <= 0 && end >= start { + req.ContentLength = end - start + } + req.GetBody = func() (io.ReadCloser, error) { + if _, err := seeker.Seek(start, io.SeekStart); err != nil { + return nil, err + } + return io.NopCloser(seeker), nil + } +} + func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { fullRequestURL, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + req, err := newTaskHTTPRequest(c.Request.Method, fullRequestURL, requestBody, info) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - applyUpstreamContentLength(req, info) - req.GetBody = func() (io.ReadCloser, error) { - return io.NopCloser(requestBody), nil - } err = a.BuildRequestHeader(c, req, info) if err != nil { diff --git a/relay/channel/api_request_test.go b/relay/channel/api_request_test.go index 37cf56f..e258de3 100644 --- a/relay/channel/api_request_test.go +++ b/relay/channel/api_request_test.go @@ -1,8 +1,10 @@ package channel import ( + "io" "net/http" "net/http/httptest" + "strings" "testing" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" @@ -10,6 +12,66 @@ import ( "github.com/stretchr/testify/require" ) +type countingReader struct { + reads int +} + +func (r *countingReader) Read([]byte) (int, error) { + r.reads++ + return 0, io.EOF +} + +type customReadSeeker struct { + reader *strings.Reader +} + +func (r *customReadSeeker) Read(p []byte) (int, error) { + return r.reader.Read(p) +} + +func (r *customReadSeeker) Seek(offset int64, whence int) (int64, error) { + return r.reader.Seek(offset, whence) +} + +func TestNewTaskHTTPRequestDoesNotPreReadRequestBody(t *testing.T) { + t.Parallel() + + body := &countingReader{} + info := &relaycommon.RelayInfo{ + UpstreamRequestBodySize: 512, + } + + req, err := newTaskHTTPRequest(http.MethodPost, "https://example.com/tasks", body, info) + + require.NoError(t, err) + require.Equal(t, 0, body.reads) + require.Equal(t, int64(512), req.ContentLength) + require.Nil(t, req.GetBody) + + _, _ = req.Body.Read(make([]byte, 1)) + require.Equal(t, 1, body.reads) +} + +func TestNewTaskHTTPRequestUsesSeekableGetBodyWithoutBuffering(t *testing.T) { + t.Parallel() + + body := &customReadSeeker{reader: strings.NewReader("abcdef")} + _, err := body.Seek(2, io.SeekStart) + require.NoError(t, err) + + req, err := newTaskHTTPRequest(http.MethodPost, "https://example.com/tasks", body, &relaycommon.RelayInfo{}) + + require.NoError(t, err) + require.Equal(t, int64(4), req.ContentLength) + require.NotNil(t, req.GetBody) + + replay, err := req.GetBody() + require.NoError(t, err) + replayed, err := io.ReadAll(replay) + require.NoError(t, err) + require.Equal(t, "cdef", string(replayed)) +} + func TestProcessHeaderOverride_ChannelTestSkipsPassthroughRules(t *testing.T) { t.Parallel() diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 3500624..2967f3a 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -13,6 +13,7 @@ import ( relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/helper" "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/service/openaicompat" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" @@ -481,27 +482,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo createAt = int64(streamResp.Response.CreatedAt) } if streamResp.Response.Usage != nil { - if streamResp.Response.Usage.InputTokens != 0 { - usage.PromptTokens = streamResp.Response.Usage.InputTokens - usage.InputTokens = streamResp.Response.Usage.InputTokens - } - if streamResp.Response.Usage.OutputTokens != 0 { - usage.CompletionTokens = streamResp.Response.Usage.OutputTokens - usage.OutputTokens = streamResp.Response.Usage.OutputTokens - } - if streamResp.Response.Usage.TotalTokens != 0 { - usage.TotalTokens = streamResp.Response.Usage.TotalTokens - } else { - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens - } - if streamResp.Response.Usage.InputTokensDetails != nil { - usage.PromptTokensDetails.CachedTokens = streamResp.Response.Usage.InputTokensDetails.CachedTokens - usage.PromptTokensDetails.ImageTokens = streamResp.Response.Usage.InputTokensDetails.ImageTokens - usage.PromptTokensDetails.AudioTokens = streamResp.Response.Usage.InputTokensDetails.AudioTokens - } - if streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens != 0 { - usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens - } + usage = openaicompat.UsageFromResponsesUsage(streamResp.Response.Usage) } if outputText.Len() == 0 { completedText := service.ExtractOutputTextFromResponses(streamResp.Response) diff --git a/relay/channel/openai/empty_completion_test.go b/relay/channel/openai/empty_completion_test.go index 5baf78b..c780b62 100644 --- a/relay/channel/openai/empty_completion_test.go +++ b/relay/channel/openai/empty_completion_test.go @@ -354,3 +354,47 @@ func TestOaiResponsesToChatStreamHandlerRetriesEmptyOnEOFBeforeWriting(t *testin t.Fatalf("response body = %q, want empty before retry", body) } } + +func TestOaiResponsesToChatStreamHandlerCopiesDetailedUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-test","output":[]}}`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"gpt-test","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":2,"cached_creation_tokens":3,"text_tokens":4,"image_tokens":1,"audio_tokens":0},"completion_tokens_details":{"text_tokens":5,"reasoning_tokens":6}}}}`, + `data: [DONE]`, + ``, + }, "\n"))), + } + info := &relaycommon.RelayInfo{ + DisablePing: true, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gpt-test", + }, + } + + usage, err := OaiResponsesToChatStreamHandler(c, info, resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if usage == nil { + t.Fatal("usage is nil") + } + if usage.PromptTokens != 10 || usage.CompletionTokens != 5 || usage.TotalTokens != 15 { + t.Fatalf("usage tokens = %#v", usage) + } + if usage.PromptTokensDetails.CachedTokens != 2 || + usage.PromptTokensDetails.CachedCreationTokens != 3 || + usage.PromptTokensDetails.TextTokens != 4 || + usage.PromptTokensDetails.ImageTokens != 1 { + t.Fatalf("prompt details = %#v", usage.PromptTokensDetails) + } + if usage.CompletionTokenDetails.TextTokens != 5 || + usage.CompletionTokenDetails.ReasoningTokens != 6 { + t.Fatalf("completion details = %#v", usage.CompletionTokenDetails) + } +} diff --git a/relay/channel/openai/responses_via_chat.go b/relay/channel/openai/responses_via_chat.go index be33c86..214b4fe 100644 --- a/relay/channel/openai/responses_via_chat.go +++ b/relay/channel/openai/responses_via_chat.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" @@ -83,12 +84,14 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo return } - var errorResp dto.OpenAITextResponse - if err := common.UnmarshalJsonStr(data, &errorResp); err == nil { - if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode) - sr.Stop(streamErr) - return + if strings.Contains(data, `"error"`) { + var errorResp dto.OpenAITextResponse + if err := common.UnmarshalJsonStr(data, &errorResp); err == nil { + if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode) + sr.Stop(streamErr) + return + } } } diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 5743f4f..4efe217 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -22,6 +22,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pkg/errors" "github.com/samber/lo" + "github.com/tidwall/gjson" ) // ============================ @@ -105,26 +106,38 @@ type responseTask struct { type TaskAdaptor struct { taskcommon.BaseBilling - ChannelType int - apiKey string - baseURL string - taskProtocol string - taskProtocolConfig dto.TaskProtocolConfig + ChannelType int + apiKey string + baseURL string } func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.ChannelType = info.ChannelType a.baseURL = info.ChannelBaseUrl a.apiKey = info.ApiKey - a.taskProtocol = info.ChannelOtherSettings.TaskProtocol - a.taskProtocolConfig = normalizeSeedanceMediaProtocolConfig(info.ChannelOtherSettings.TaskProtocolConfig) } // ValidateRequestAndSetAction parses body, validates fields and sets default action. func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { - if a.useSeedanceMediaProtocol() { - return a.validateSeedanceMediaTaskRequest(c, info) + if info != nil && info.ChannelMeta != nil && taskcommon.UseConfiguredTaskProtocol(info.ChannelMeta.ChannelOtherSettings) && + taskcommon.EffectiveTaskProtocolConfig(info.ChannelMeta.ChannelOtherSettings).RequestBodyMode != taskcommon.TaskRequestBodyModeAdapter { + var req relaycommon.TaskSubmitReq + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + if strings.TrimSpace(req.Model) == "" { + return service.TaskErrorWrapperLocal(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest) + } + if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { + req.Images = []string{req.Image} + } + if len(req.Images) == 0 && len(req.ReferenceImages) > 0 { + req.Images = append([]string{}, req.ReferenceImages...) + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, req) + return nil } + // Accept only POST /v1/video/generations as "generate" action. taskErr = relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) if taskErr != nil { @@ -135,9 +148,6 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom // BuildRequestURL constructs the upstream URL. func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { - if a.useSeedanceMediaProtocol() { - return buildSeedanceMediaURL(a.baseURL, a.taskProtocolConfig.SubmitPath), nil - } fallback := fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL) return taskcommon.BuildTaskSubmitURL(info, fallback), nil } @@ -156,7 +166,7 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf if err != nil { return nil } - resolution, hasVideo := a.resolveSeedanceBillingInputs(c, &req) + resolution, hasVideo := a.resolveBillingInputs(c, &req) ratio, ok := GetVideoInputRatio(info.OriginModelName, resolution, hasVideo) if !ok || ratio == 1.0 { return nil @@ -164,27 +174,26 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf return map[string]float64{"video_input": ratio} } -func (a *TaskAdaptor) resolveSeedanceBillingInputs(c *gin.Context, req *relaycommon.TaskSubmitReq) (string, bool) { +func (a *TaskAdaptor) resolveBillingInputs(c *gin.Context, req *relaycommon.TaskSubmitReq) (string, bool) { if req == nil { return "", false } - hasVideo := hasVideoInMetadata(req.Metadata) || hasVideoInMetadata(getSeedanceMediaRawRequest(c)) + raw := map[string]any{} + if c != nil && c.Request != nil && c.Request.Body != nil { + _ = common.UnmarshalBodyReusable(c, &raw) + } + hasVideo := hasVideoInMetadata(req.Metadata) + hasVideo = hasVideo || hasVideoInMetadata(raw) resolution := req.Resolution - if !a.useSeedanceMediaProtocol() { - payload, err := a.convertToRequestPayload(req) - if err == nil && payload != nil { - if payload.Resolution != nil && *payload.Resolution != "" { - resolution = *payload.Resolution - } - hasVideo = hasVideo || hasVideoInContent(payload.Content) - } - return resolution, hasVideo + if rawResolution := strings.TrimSpace(common.Interface2String(raw["resolution"])); rawResolution != "" { + resolution = rawResolution } - if payload, err := a.convertToGenericMediaRequest(req); err == nil && payload != nil { - applySeedanceMediaRawFields(payload, getSeedanceMediaRawRequest(c)) - if payload.Resolution != "" { - resolution = payload.Resolution + payload, err := a.convertToRequestPayload(req) + if err == nil && payload != nil { + if payload.Resolution != nil && *payload.Resolution != "" { + resolution = *payload.Resolution } + hasVideo = hasVideo || hasVideoInContent(payload.Content) } return resolution, hasVideo } @@ -263,23 +272,6 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn return nil, err } - if a.useSeedanceMediaProtocol() { - body, err := a.convertToSeedanceMediaRequest(c, &req) - if err != nil { - return nil, errors.Wrap(err, "convert seedance media request failed") - } - if info.IsModelMapped { - body.Model = info.UpstreamModelName - } else { - info.UpstreamModelName = body.Model - } - data, err := common.Marshal(body.ToMap()) - if err != nil { - return nil, err - } - return bytes.NewReader(data), nil - } - body, err := a.convertToRequestPayload(&req) if err != nil { return nil, errors.Wrap(err, "convert request payload failed") @@ -314,10 +306,6 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return taskID, responseBody, configuredErr } - if a.useSeedanceMediaProtocol() { - return a.doSeedanceMediaResponse(c, responseBody, info) - } - // Parse Doubao response var dResp responsePayload if err := common.Unmarshal(responseBody, &dResp); err != nil { @@ -348,9 +336,6 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy } uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID) - if a.useSeedanceMediaProtocol() { - uri = a.seedanceMediaQueryURL(baseUrl, taskID) - } uri = taskcommon.BuildTaskQueryURL(baseUrl, body, uri) req, err := http.NewRequest(http.MethodGet, uri, nil) @@ -414,8 +399,14 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* } func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { - if a.useSeedanceMediaProtocol() || looksLikeSeedanceMediaTask(respBody) { - return a.parseSeedanceMediaTaskResult(respBody) + if looksLikeSeedanceMediaTask(respBody) { + if taskResult, ok, err := taskcommon.ParseConfiguredTaskResult(respBody, dto.ChannelOtherSettings{ + TaskProtocol: taskcommon.TaskProtocolGenericVideo, + }); err != nil { + return nil, err + } else if ok { + return taskResult, nil + } } resTask := responseTask{} @@ -456,8 +447,36 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e } func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { - if a.useSeedanceMediaProtocol() || looksLikeSeedanceMediaTask(originTask.Data) { - return a.convertSeedanceMediaToOpenAIVideo(originTask) + if body, ok, err := taskcommon.ConvertConfiguredTaskToOpenAIVideo(originTask); ok || err != nil { + return body, err + } + if originTask != nil && looksLikeSeedanceMediaTask(originTask.Data) { + cfg := taskcommon.NormalizeTaskProtocolConfig(nil) + openAIVideo := dto.NewOpenAIVideo() + openAIVideo.ID = originTask.TaskID + openAIVideo.TaskID = originTask.TaskID + openAIVideo.Status = originTask.Status.ToVideoStatus() + openAIVideo.SetProgressStr(originTask.Progress) + urlValue := originTask.GetResultURL() + if urlValue == "" { + urlValue = taskcommon.ExtractConfiguredResultURL(originTask.Data, cfg.ResultURLPaths) + } + openAIVideo.SetMetadata("url", urlValue) + openAIVideo.CreatedAt = originTask.CreatedAt + openAIVideo.CompletedAt = originTask.UpdatedAt + openAIVideo.Model = originTask.Properties.OriginModelName + + if originTask.Status == model.TaskStatusFailure { + message := originTask.FailReason + if message == "" { + message = taskcommon.StringFromGJSONPath(originTask.Data, cfg.ErrorMessagePath) + } + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: message, + Code: "upstream_error", + } + } + return common.Marshal(openAIVideo) } var dResp responseTask @@ -484,3 +503,11 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro return common.Marshal(openAIVideo) } + +func looksLikeSeedanceMediaTask(data []byte) bool { + if len(data) == 0 { + return false + } + return gjson.GetBytes(data, "task_id").Exists() && + (gjson.GetBytes(data, "status").Exists() || gjson.GetBytes(data, "object").String() == "media.task") +} diff --git a/relay/channel/task/doubao/adaptor_media_protocol_test.go b/relay/channel/task/doubao/adaptor_billing_test.go similarity index 68% rename from relay/channel/task/doubao/adaptor_media_protocol_test.go rename to relay/channel/task/doubao/adaptor_billing_test.go index 5119e5e..8c23611 100644 --- a/relay/channel/task/doubao/adaptor_media_protocol_test.go +++ b/relay/channel/task/doubao/adaptor_billing_test.go @@ -1,10 +1,15 @@ package doubao import ( + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/gin-gonic/gin" @@ -12,78 +17,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestConvertToGenericMediaRequestFromTopLevelFields(t *testing.T) { - var req relaycommon.TaskSubmitReq - err := common.Unmarshal([]byte(`{ - "aspect_ratio": "16:9", - "duration_seconds": 5, - "end_image": "https://example.com/last-frame.png", - "image": "https://example.com/first-frame.png", - "input_mode": "single_image", - "model": "doubao-seedance-2-0-260128", - "prompt": "让人物自然转身并看向镜头", - "resolution": "720p", - "with_audio": true - }`), &req) - require.NoError(t, err) - - payload, err := (&TaskAdaptor{}).convertToGenericMediaRequest(&req) - require.NoError(t, err) - - assert.Equal(t, "doubao-seedance-2-0-260128", payload.Model) - assert.Equal(t, "让人物自然转身并看向镜头", payload.Prompt) - assert.Equal(t, "16:9", payload.AspectRatio) - assert.Equal(t, "video_generation", payload.Capability) - assert.Equal(t, "end_frame", payload.ControlMode) - assert.Equal(t, "single_image", payload.InputMode) - assert.Equal(t, "https://example.com/first-frame.png", payload.Image) - assert.Equal(t, "https://example.com/last-frame.png", payload.EndImage) - assert.Equal(t, "720p", payload.Resolution) - require.NotNil(t, payload.DurationSeconds) - assert.Equal(t, 5, *payload.DurationSeconds) - require.NotNil(t, payload.WithAudio) - assert.True(t, *payload.WithAudio) -} - -func TestConvertToGenericMediaRequestReferenceImages(t *testing.T) { - var req relaycommon.TaskSubmitReq - err := common.Unmarshal([]byte(`{ - "model": "doubao-seedance-2-0-260128", - "prompt": "参考图片中的人物在海边回眸", - "reference_images": ["asset://asset-a", "asset://asset-b"], - "with_audio": false - }`), &req) - require.NoError(t, err) - - payload, err := (&TaskAdaptor{}).convertToGenericMediaRequest(&req) - require.NoError(t, err) - - assert.Equal(t, "multi_image", payload.InputMode) - assert.Equal(t, "reference", payload.ControlMode) - assert.Equal(t, []string{"asset://asset-a", "asset://asset-b"}, payload.ReferenceImages) - require.NotNil(t, payload.WithAudio) - assert.False(t, *payload.WithAudio) -} - -func TestParseSeedanceMediaTaskResult(t *testing.T) { - taskInfo, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ - "object": "media.task", - "task_id": "6c1a4eeaaee14736a78170ddbfff8361", - "status": "succeeded", - "progress": 100, - "result": { - "url": "https://example.com/result.mp4", - "duration_seconds": 5 - } - }`)) - require.NoError(t, err) - - assert.Equal(t, model.TaskStatusSuccess, taskInfo.Status) - assert.Equal(t, "100%", taskInfo.Progress) - assert.Equal(t, "https://example.com/result.mp4", taskInfo.Url) - assert.Equal(t, "6c1a4eeaaee14736a78170ddbfff8361", taskInfo.TaskID) -} - func TestGetVideoInputRatioUsesResolutionAndVideoInput(t *testing.T) { ratio, ok := GetVideoInputRatio("doubao-seedance-2-0-260128", "", false) require.True(t, ok) @@ -207,7 +140,7 @@ func TestEstimateBillingIgnoresUnusableVideoInput(t *testing.T) { } } -func TestEstimateBillingUsesTopLevelSeedanceMediaResolution(t *testing.T) { +func TestEstimateBillingUsesTopLevelVideoResolution(t *testing.T) { c, _ := gin.CreateTestContext(nil) info := &relaycommon.RelayInfo{ OriginModelName: "doubao-seedance-2-0-260128", @@ -223,6 +156,28 @@ func TestEstimateBillingUsesTopLevelSeedanceMediaResolution(t *testing.T) { assert.InDelta(t, 51.0/46.0, ratios["video_input"], 1e-9) } +func TestEstimateBillingUsesTopLevelRawVideoInput(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model": "doubao-seedance-2-0-260128", + "prompt": "test", + "resolution": "1080p", + "video_url": "https://example.com/input.mp4" + }`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, 31.0/46.0, ratios["video_input"], 1e-9) +} + func TestEstimateBillingUsesLegacyRequestPayloadResolution(t *testing.T) { c, _ := gin.CreateTestContext(nil) info := &relaycommon.RelayInfo{ @@ -241,7 +196,7 @@ func TestEstimateBillingUsesLegacyRequestPayloadResolution(t *testing.T) { require.Nil(t, ratios) } -func TestConvertToRequestPayloadPreservesSeedanceFields(t *testing.T) { +func TestConvertToRequestPayloadPreservesDoubaoVideoFields(t *testing.T) { var req relaycommon.TaskSubmitReq err := common.Unmarshal([]byte(`{ "model": "doubao-seedance-2-0-260128", @@ -296,3 +251,71 @@ func TestConvertToRequestPayloadPreservesExplicitEmptyOptionalStrings(t *testing assert.Contains(t, string(data), `"resolution":""`) assert.Contains(t, string(data), `"ratio":""`) } + +func TestValidateConfiguredTaskProtocolAllowsPromptlessMediaRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model": "doubao-seedance-2-0-260128", + "image": "https://example.com/frame.png" + }`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: taskcommon.TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: taskcommon.TaskRequestBodyModeMediaGeneration, + }, + }, + }, + } + + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + + require.Nil(t, taskErr) + require.Equal(t, "generate", info.Action) + req, err := relaycommon.GetTaskRequest(c) + require.NoError(t, err) + require.Equal(t, "doubao-seedance-2-0-260128", req.Model) + require.Equal(t, []string{"https://example.com/frame.png"}, req.Images) +} + +func TestParseSeedanceMediaTaskResultByShape(t *testing.T) { + taskInfo, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "object": "media.task", + "task_id": "task_123", + "status": "succeeded", + "progress": 100, + "result": { + "url": "https://example.com/result.mp4" + } + }`)) + + require.NoError(t, err) + require.NotNil(t, taskInfo) + assert.Equal(t, "task_123", taskInfo.TaskID) + assert.Equal(t, model.TaskStatusSuccess, taskInfo.Status) + assert.Equal(t, "100%", taskInfo.Progress) + assert.Equal(t, "https://example.com/result.mp4", taskInfo.Url) +} + +func TestConvertSeedanceMediaTaskToOpenAIVideoByShape(t *testing.T) { + body, err := (&TaskAdaptor{}).ConvertToOpenAIVideo(&model.Task{ + TaskID: "task_123", + Status: model.TaskStatusSuccess, + Progress: "100%", + Data: []byte(`{"object":"media.task","task_id":"task_123","status":"succeeded","result":{"url":"https://example.com/result.mp4"}}`), + CreatedAt: 1710000000, + UpdatedAt: 1710000100, + Properties: model.Properties{ + OriginModelName: "doubao-seedance-2-0-260128", + }, + }) + + require.NoError(t, err) + assert.Contains(t, string(body), `"url":"https://example.com/result.mp4"`) + assert.Contains(t, string(body), `"task_id":"task_123"`) +} diff --git a/relay/channel/task/doubao/media_protocol.go b/relay/channel/task/doubao/media_protocol.go deleted file mode 100644 index 75423a6..0000000 --- a/relay/channel/task/doubao/media_protocol.go +++ /dev/null @@ -1,591 +0,0 @@ -package doubao - -import ( - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - - "github.com/MAX-API-Next/MAX-API/common" - "github.com/MAX-API-Next/MAX-API/constant" - "github.com/MAX-API-Next/MAX-API/dto" - "github.com/MAX-API-Next/MAX-API/model" - "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" - relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" - "github.com/MAX-API-Next/MAX-API/service" - - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" -) - -const ( - taskProtocolSeedanceOfficialMedia = "seedance_official_media" - seedanceMediaRawRequestKey = "doubao_seedance_media_raw_request" -) - -type seedanceMediaRequest struct { - Model string - Prompt string - AspectRatio string - Capability string - ControlMode string - DurationSeconds *int - EndImage string - Image string - InputMode string - ReferenceImages []string - Resolution string - WithAudio *bool - Extra map[string]interface{} -} - -func (r *seedanceMediaRequest) ToMap() map[string]interface{} { - out := map[string]interface{}{} - for k, v := range r.Extra { - out[k] = v - } - setString(out, "model", r.Model) - setString(out, "prompt", r.Prompt) - setString(out, "aspect_ratio", r.AspectRatio) - setString(out, "capability", r.Capability) - setString(out, "control_mode", r.ControlMode) - setString(out, "end_image", r.EndImage) - setString(out, "image", r.Image) - setString(out, "input_mode", r.InputMode) - setString(out, "resolution", r.Resolution) - if r.DurationSeconds != nil { - out["duration_seconds"] = *r.DurationSeconds - } - if len(r.ReferenceImages) > 0 { - out["reference_images"] = r.ReferenceImages - } - if r.WithAudio != nil { - out["with_audio"] = *r.WithAudio - } - return out -} - -func normalizeSeedanceMediaProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocolConfig { - return taskcommon.NormalizeTaskProtocolConfig(input) -} - -func (a *TaskAdaptor) useSeedanceMediaProtocol() bool { - return strings.EqualFold(strings.TrimSpace(a.taskProtocol), taskProtocolSeedanceOfficialMedia) -} - -func (a *TaskAdaptor) seedanceMediaConfig() dto.TaskProtocolConfig { - return normalizeSeedanceMediaProtocolConfig(&a.taskProtocolConfig) -} - -func (a *TaskAdaptor) captureSeedanceMediaRequest(c *gin.Context) error { - var raw map[string]interface{} - if err := common.UnmarshalBodyReusable(c, &raw); err != nil { - return err - } - c.Set(seedanceMediaRawRequestKey, raw) - return nil -} - -func (a *TaskAdaptor) validateSeedanceMediaTaskRequest(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { - var req relaycommon.TaskSubmitReq - if err := common.UnmarshalBodyReusable(c, &req); err != nil { - return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) - } - if strings.TrimSpace(req.Model) == "" { - return service.TaskErrorWrapperLocal(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest) - } - if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { - req.Images = []string{req.Image} - } - if len(req.Images) == 0 && len(req.ReferenceImages) > 0 { - req.Images = append([]string{}, req.ReferenceImages...) - } - if err := a.captureSeedanceMediaRequest(c); err != nil { - return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) - } - relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, req) - return nil -} - -func (a *TaskAdaptor) convertToSeedanceMediaRequest(c *gin.Context, req *relaycommon.TaskSubmitReq) (*seedanceMediaRequest, error) { - payload, err := a.convertToGenericMediaRequest(req) - if err != nil { - return nil, err - } - raw := getSeedanceMediaRawRequest(c) - applySeedanceMediaRawFields(payload, raw) - inferSeedanceMediaModes(payload) - return payload, nil -} - -func (a *TaskAdaptor) convertToGenericMediaRequest(req *relaycommon.TaskSubmitReq) (*seedanceMediaRequest, error) { - if req == nil { - return nil, fmt.Errorf("request is nil") - } - payload := &seedanceMediaRequest{ - Model: req.Model, - Prompt: req.Prompt, - AspectRatio: req.AspectRatio, - Capability: firstNonEmpty(req.Capability, "video_generation"), - ControlMode: req.ControlMode, - DurationSeconds: req.DurationSeconds, - EndImage: req.EndImage, - Image: firstNonEmpty(req.Image, firstString(req.Images)), - InputMode: req.InputMode, - ReferenceImages: append([]string{}, req.ReferenceImages...), - Resolution: req.Resolution, - WithAudio: req.WithAudio, - Extra: map[string]interface{}{}, - } - if payload.DurationSeconds == nil { - if sec, ok := parsePositiveOrAutoSeconds(req.Seconds); ok { - payload.DurationSeconds = &sec - } else if req.Duration != 0 { - sec := req.Duration - payload.DurationSeconds = &sec - } - } - applySeedanceMediaMetadata(payload, req.Metadata) - inferSeedanceMediaModes(payload) - return payload, nil -} - -func applySeedanceMediaRawFields(payload *seedanceMediaRequest, raw map[string]interface{}) { - if payload == nil || raw == nil { - return - } - setPayloadString(raw, "model", &payload.Model) - setPayloadString(raw, "prompt", &payload.Prompt) - setPayloadString(raw, "aspect_ratio", &payload.AspectRatio) - setPayloadString(raw, "capability", &payload.Capability) - setPayloadString(raw, "control_mode", &payload.ControlMode) - setPayloadString(raw, "end_image", &payload.EndImage) - setPayloadString(raw, "image", &payload.Image) - setPayloadString(raw, "input_mode", &payload.InputMode) - setPayloadString(raw, "resolution", &payload.Resolution) - if v, ok := raw["duration_seconds"]; ok { - if sec, exists := intFromAny(v); exists { - payload.DurationSeconds = &sec - } - } - if v, ok := raw["with_audio"]; ok { - if b, exists := boolFromAny(v); exists { - payload.WithAudio = &b - } - } - if refs, exists := stringSliceFromAny(raw["reference_images"]); exists { - payload.ReferenceImages = refs - } -} - -func applySeedanceMediaMetadata(payload *seedanceMediaRequest, metadata map[string]interface{}) { - if payload == nil || metadata == nil { - return - } - for k, v := range metadata { - switch k { - case "model": - setStringValue(v, &payload.Model) - case "prompt": - setStringValue(v, &payload.Prompt) - case "aspect_ratio", "ratio": - setStringValue(v, &payload.AspectRatio) - case "capability": - setStringValue(v, &payload.Capability) - case "control_mode": - setStringValue(v, &payload.ControlMode) - case "duration_seconds": - if sec, exists := intFromAny(v); exists { - payload.DurationSeconds = &sec - } - case "end_image", "image_tail", "tail_image": - setStringValue(v, &payload.EndImage) - case "image": - setStringValue(v, &payload.Image) - case "input_mode": - setStringValue(v, &payload.InputMode) - case "reference_images": - if refs, exists := stringSliceFromAny(v); exists { - payload.ReferenceImages = refs - } - case "resolution": - setStringValue(v, &payload.Resolution) - case "with_audio", "generate_audio": - if b, exists := boolFromAny(v); exists { - payload.WithAudio = &b - } - default: - payload.Extra[k] = v - } - } -} - -func inferSeedanceMediaModes(payload *seedanceMediaRequest) { - if payload == nil { - return - } - if payload.Capability == "" { - payload.Capability = "video_generation" - } - if payload.InputMode == "" { - switch { - case len(payload.ReferenceImages) > 0: - payload.InputMode = "multi_image" - case payload.Image != "": - payload.InputMode = "single_image" - default: - payload.InputMode = "text" - } - } - if payload.ControlMode == "" { - switch { - case payload.InputMode == "multi_image": - payload.ControlMode = "reference" - case payload.EndImage != "": - payload.ControlMode = "end_frame" - default: - payload.ControlMode = "none" - } - } -} - -func (a *TaskAdaptor) doSeedanceMediaResponse(c *gin.Context, responseBody []byte, info *relaycommon.RelayInfo) (string, []byte, *dto.TaskError) { - if taskID, handled, taskErr := taskcommon.TryHandleConfiguredSubmitResponse(c, responseBody, info); handled || taskErr != nil { - return taskID, responseBody, taskErr - } - return "", nil, taskcommon.MissingConfiguredTaskIDError() -} - -func (a *TaskAdaptor) seedanceMediaQueryURL(baseURL, taskID string) string { - cfg := a.seedanceMediaConfig() - path := strings.ReplaceAll(cfg.QueryPath, "{task_id}", url.PathEscape(taskID)) - return buildSeedanceMediaURL(baseURL, path) -} - -func (a *TaskAdaptor) parseSeedanceMediaTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { - cfg := a.seedanceMediaConfig() - result, ok, err := taskcommon.ParseConfiguredTaskResult(respBody, dto.ChannelOtherSettings{ - TaskProtocol: taskcommon.TaskProtocolLegacySeedanceMedia, - TaskProtocolConfig: &cfg, - }) - if err != nil { - return nil, err - } - if ok { - return result, nil - } - return &relaycommon.TaskInfo{ - Code: 0, - Status: string(model.TaskStatusInProgress), - Progress: taskcommon.ProgressInProgress, - }, nil -} - -func (a *TaskAdaptor) convertSeedanceMediaToOpenAIVideo(originTask *model.Task) ([]byte, error) { - if body, ok, err := taskcommon.ConvertConfiguredTaskToOpenAIVideo(originTask); ok || err != nil { - return body, err - } - cfg := a.seedanceMediaConfig() - if !a.useSeedanceMediaProtocol() { - cfg = seedanceMediaConfigFromTask(originTask) - } - openAIVideo := dto.NewOpenAIVideo() - openAIVideo.ID = originTask.TaskID - openAIVideo.TaskID = originTask.TaskID - openAIVideo.Status = originTask.Status.ToVideoStatus() - openAIVideo.SetProgressStr(originTask.Progress) - urlValue := originTask.GetResultURL() - if urlValue == "" { - urlValue = extractSeedanceMediaResultURL(originTask.Data, cfg.ResultURLPaths) - } - openAIVideo.SetMetadata("url", urlValue) - openAIVideo.CreatedAt = firstTimestamp(originTask.CreatedAt, timestampFromGJSONPath(originTask.Data, cfg.CreatedAtPath)) - openAIVideo.CompletedAt = firstTimestamp(originTask.UpdatedAt, timestampFromGJSONPath(originTask.Data, cfg.UpdatedAtPath)) - openAIVideo.Model = originTask.Properties.OriginModelName - - if originTask.Status == model.TaskStatusFailure { - openAIVideo.Error = &dto.OpenAIVideoError{ - Message: firstNonEmpty(originTask.FailReason, stringFromGJSONPath(originTask.Data, cfg.ErrorMessagePath)), - Code: "upstream_error", - } - } - return common.Marshal(openAIVideo) -} - -func seedanceMediaConfigFromTask(task *model.Task) dto.TaskProtocolConfig { - if cfg, ok := taskcommon.TaskProtocolConfigFromTask(task); ok { - return cfg - } - cfg := normalizeSeedanceMediaProtocolConfig(nil) - if task == nil || task.ChannelId == 0 { - return cfg - } - ch, err := model.GetChannelById(task.ChannelId, true) - if err != nil || ch == nil { - return cfg - } - settings := ch.GetOtherSettings() - if strings.EqualFold(settings.TaskProtocol, taskProtocolSeedanceOfficialMedia) { - return normalizeSeedanceMediaProtocolConfig(settings.TaskProtocolConfig) - } - return cfg -} - -func mapSeedanceMediaStatus(status string, cfg dto.TaskProtocolConfig) string { - normalized := strings.ToLower(strings.TrimSpace(status)) - if mapped, ok := cfg.StatusMap[normalized]; ok { - return normalizeInternalTaskStatus(mapped) - } - return normalizeInternalTaskStatus(normalized) -} - -func normalizeInternalTaskStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case "submitted": - return string(model.TaskStatusSubmitted) - case "queued", "pending": - return string(model.TaskStatusQueued) - case "in_progress", "running", "processing": - return string(model.TaskStatusInProgress) - case "success", "succeeded", "completed": - return string(model.TaskStatusSuccess) - case "failure", "failed": - return string(model.TaskStatusFailure) - default: - return string(model.TaskStatusInProgress) - } -} - -func looksLikeSeedanceMediaTask(data []byte) bool { - if len(data) == 0 { - return false - } - return gjson.GetBytes(data, "task_id").Exists() && - (gjson.GetBytes(data, "status").Exists() || gjson.GetBytes(data, "object").String() == "media.task") -} - -func buildSeedanceMediaURL(baseURL, path string) string { - if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - return path - } - baseURL = strings.TrimRight(baseURL, "/") - path = strings.TrimLeft(path, "/") - if path == "" { - return baseURL - } - if parsed, err := url.Parse(baseURL); err == nil && parsed.Path != "" { - basePath := strings.Trim(parsed.Path, "/") - if path == basePath { - path = "" - } else if strings.HasPrefix(path, basePath+"/") { - path = strings.TrimPrefix(path, basePath+"/") - } - } - if path == "" { - return baseURL - } - return baseURL + "/" + path -} - -func getSeedanceMediaRawRequest(c *gin.Context) map[string]interface{} { - if c == nil { - return nil - } - v, ok := c.Get(seedanceMediaRawRequestKey) - if !ok { - return nil - } - raw, _ := v.(map[string]interface{}) - return raw -} - -func stringFromGJSONPath(data []byte, path string) string { - if path == "" { - return "" - } - result := gjson.GetBytes(data, path) - if !result.Exists() { - return "" - } - if result.IsObject() || result.IsArray() { - return result.Raw - } - return strings.TrimSpace(result.String()) -} - -func extractSeedanceMediaResultURL(data []byte, paths []string) string { - for _, path := range paths { - result := gjson.GetBytes(data, path) - if !result.Exists() { - continue - } - if result.IsObject() { - if urlValue := firstGJSONString(result.Raw, "url", "video_url", "output_url"); urlValue != "" { - return urlValue - } - continue - } - value := strings.TrimSpace(result.String()) - if value == "" { - continue - } - if common.IsJsonObject(value) { - if urlValue := firstGJSONString(value, "url", "video_url", "output_url"); urlValue != "" { - return urlValue - } - continue - } - if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "data:") { - return value - } - } - return "" -} - -func firstGJSONString(jsonText string, paths ...string) string { - for _, path := range paths { - value := strings.TrimSpace(gjson.Get(jsonText, path).String()) - if value != "" { - return value - } - } - return "" -} - -func normalizeMediaProgress(progress string, status string) string { - progress = strings.TrimSpace(progress) - if progress == "" { - switch status { - case string(model.TaskStatusSuccess), string(model.TaskStatusFailure): - return "100%" - case string(model.TaskStatusQueued), string(model.TaskStatusSubmitted): - return "10%" - default: - return "50%" - } - } - if strings.HasSuffix(progress, "%") { - return progress - } - return progress + "%" -} - -func timestampFromGJSONPath(data []byte, path string) int64 { - value := stringFromGJSONPath(data, path) - if value == "" { - return 0 - } - if n, err := strconv.ParseInt(value, 10, 64); err == nil { - return n - } - return 0 -} - -func firstTimestamp(values ...int64) int64 { - for _, value := range values { - if value > 0 { - return value - } - } - return 0 -} - -func parsePositiveOrAutoSeconds(value string) (int, bool) { - if strings.TrimSpace(value) == "" { - return 0, false - } - sec, err := strconv.Atoi(value) - if err != nil { - return 0, false - } - return sec, sec > 0 || sec == -1 -} - -func intFromAny(value interface{}) (int, bool) { - switch v := value.(type) { - case int: - return v, true - case int64: - return int(v), true - case float64: - return int(v), true - case string: - n, err := strconv.Atoi(strings.TrimSpace(v)) - return n, err == nil - default: - return 0, false - } -} - -func boolFromAny(value interface{}) (bool, bool) { - switch v := value.(type) { - case bool: - return v, true - case string: - b, err := strconv.ParseBool(strings.TrimSpace(v)) - return b, err == nil - default: - return false, false - } -} - -func stringSliceFromAny(value interface{}) ([]string, bool) { - switch v := value.(type) { - case []string: - return append([]string{}, v...), true - case []interface{}: - out := make([]string, 0, len(v)) - for _, item := range v { - s := strings.TrimSpace(common.Interface2String(item)) - if s != "" { - out = append(out, s) - } - } - return out, len(out) > 0 - default: - return nil, false - } -} - -func setPayloadString(raw map[string]interface{}, key string, target *string) { - if raw == nil || target == nil { - return - } - if value, ok := raw[key]; ok { - setStringValue(value, target) - } -} - -func setStringValue(value interface{}, target *string) { - if target == nil { - return - } - if s, ok := value.(string); ok { - *target = strings.TrimSpace(s) - } -} - -func setString(out map[string]interface{}, key string, value string) { - if strings.TrimSpace(value) != "" { - out[key] = value - } -} - -func firstString(values []string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} diff --git a/relay/channel/task/taskcommon/path_config.go b/relay/channel/task/taskcommon/path_config.go index 592abcd..a7780ae 100644 --- a/relay/channel/task/taskcommon/path_config.go +++ b/relay/channel/task/taskcommon/path_config.go @@ -24,10 +24,32 @@ func ValidateTaskProtocolSettings(otherSettings string) error { if err := common.UnmarshalJsonStr(otherSettings, &settings); err != nil { return nil } + if strings.TrimSpace(settings.TaskProtocol) != "" && !UseConfiguredTaskProtocol(settings) { + return fmt.Errorf("task_protocol must be %s", TaskProtocolGenericVideo) + } cfg := settings.TaskProtocolConfig if cfg == nil { return nil } + if strings.TrimSpace(cfg.RequestBodyMode) != "" { + if !UseConfiguredTaskProtocol(settings) { + return fmt.Errorf("task_protocol_config.request_body_mode requires task_protocol=%s", TaskProtocolGenericVideo) + } + switch strings.ToLower(strings.TrimSpace(cfg.RequestBodyMode)) { + case TaskRequestBodyModeAdapter, TaskRequestBodyModePassThrough, TaskRequestBodyModeFieldMapping, TaskRequestBodyModeMediaGeneration: + default: + return fmt.Errorf("task_protocol_config.request_body_mode must be one of %s, %s, %s, %s", TaskRequestBodyModeAdapter, TaskRequestBodyModePassThrough, TaskRequestBodyModeFieldMapping, TaskRequestBodyModeMediaGeneration) + } + } + if len(cfg.RequestBodyMapping) > 0 || len(cfg.RequestBodyDefaults) > 0 { + if !UseConfiguredTaskProtocol(settings) { + return fmt.Errorf("task_protocol_config request body mapping requires task_protocol=%s", TaskProtocolGenericVideo) + } + } + if strings.EqualFold(strings.TrimSpace(cfg.RequestBodyMode), TaskRequestBodyModeFieldMapping) && + len(cfg.RequestBodyMapping) == 0 && len(cfg.RequestBodyDefaults) == 0 { + return fmt.Errorf("task_protocol_config.request_body_mapping or request_body_defaults is required when request_body_mode=%s", TaskRequestBodyModeFieldMapping) + } queryPath := strings.TrimSpace(cfg.QueryPath) if queryPath == "" { return nil @@ -45,7 +67,7 @@ func WithTaskProtocolConfig(body map[string]any, settings dto.ChannelOtherSettin body = map[string]any{} } if UseConfiguredTaskProtocol(settings) { - body[taskProtocolConfigBodyKey] = NormalizeTaskProtocolConfig(settings.TaskProtocolConfig) + body[taskProtocolConfigBodyKey] = EffectiveTaskProtocolConfig(settings) } else if settings.TaskProtocolConfig != nil { body[taskProtocolConfigBodyKey] = settings.TaskProtocolConfig } @@ -72,7 +94,7 @@ func BuildTaskSubmitURL(info *relaycommon.RelayInfo, fallback string) string { } path := "" if UseConfiguredTaskProtocol(info.ChannelOtherSettings) { - cfg := NormalizeTaskProtocolConfig(info.ChannelOtherSettings.TaskProtocolConfig) + cfg := EffectiveTaskProtocolConfig(info.ChannelOtherSettings) path = cfg.SubmitPath } else if info.ChannelOtherSettings.TaskProtocolConfig != nil { path = info.ChannelOtherSettings.TaskProtocolConfig.SubmitPath @@ -109,9 +131,32 @@ func BuildConfiguredTaskURL(baseURL string, path string, values map[string]strin if strings.HasPrefix(path, "?") { return baseURL + path } + path = trimDuplicatedBasePath(baseURL, path) + if path == "" { + return baseURL + } return baseURL + "/" + strings.TrimLeft(path, "/") } +func trimDuplicatedBasePath(baseURL string, path string) string { + parsed, err := url.Parse(baseURL) + if err != nil { + return strings.TrimLeft(path, "/") + } + basePath := strings.Trim(parsed.EscapedPath(), "/") + if basePath == "" { + return strings.TrimLeft(path, "/") + } + path = strings.TrimLeft(path, "/") + if path == basePath { + return "" + } + if strings.HasPrefix(path, basePath+"/") { + return strings.TrimPrefix(path, basePath+"/") + } + return path +} + func taskProtocolConfigFromBody(body map[string]any) *dto.TaskProtocolConfig { if body == nil { return nil diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index ad38f27..14ba632 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -1,9 +1,12 @@ package taskcommon import ( + "bytes" + "io" "net/http/httptest" "testing" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" @@ -61,6 +64,12 @@ func TestBuildConfiguredTaskURLUsesQueryEscaping(t *testing.T) { assert.Equal(t, "https://upstream.example.com/v1/videos?operation=models%2Fveo-3%2Foperations%2Fa%26b&model=video+model&id=task%2Fabc%26x%3D1", got) } +func TestBuildConfiguredTaskURLDeduplicatesBasePath(t *testing.T) { + got := BuildConfiguredTaskURL("https://upstream.example.com/v1", "/v1/videos/create", nil) + + assert.Equal(t, "https://upstream.example.com/v1/videos/create", got) +} + func TestBuildTaskQueryURLKeepsFallbackWithoutConfig(t *testing.T) { got := BuildTaskQueryURL("https://upstream.example.com", map[string]any{ "task_id": "task_abc", @@ -69,6 +78,351 @@ func TestBuildTaskQueryURLKeepsFallbackWithoutConfig(t *testing.T) { assert.Equal(t, "https://fallback.example.com/task_abc", got) } +func TestBuildConfiguredTaskRequestBodyPassThrough(t *testing.T) { + c := newJSONTaskContext(`{ + "model": "local-model", + "prompt": "test", + "duration_seconds": 0, + "with_audio": false + }`) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "upstream-model", + IsModelMapped: true, + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: TaskRequestBodyModePassThrough, + }, + }, + }, + } + req := relaycommon.TaskSubmitReq{Model: "local-model", Prompt: "test"} + + reader, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + require.True(t, handled) + body := readJSONBody(t, reader) + assert.Equal(t, "upstream-model", gjson.GetBytes(body, "model").String()) + assert.Equal(t, "test", gjson.GetBytes(body, "prompt").String()) + assert.Equal(t, int64(0), gjson.GetBytes(body, "duration_seconds").Int()) + assert.False(t, gjson.GetBytes(body, "with_audio").Bool()) +} + +func TestBuildConfiguredTaskRequestBodyMediaGeneration(t *testing.T) { + c := newJSONTaskContext(`{ + "aspect_ratio": "16:9", + "duration_seconds": 5, + "model": "doubao-seedance-1-5-pro-251215", + "prompt": "dance", + "resolution": "720p", + "with_audio": false + }`) + req := relaycommon.TaskSubmitReq{} + require.NoError(t, common.Unmarshal([]byte(`{ + "aspect_ratio": "16:9", + "duration_seconds": 5, + "model": "doubao-seedance-1-5-pro-251215", + "prompt": "dance", + "resolution": "720p", + "with_audio": false + }`), &req)) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: TaskRequestBodyModeMediaGeneration, + }, + }, + }, + } + + reader, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + require.True(t, handled) + body := readJSONBody(t, reader) + assert.Equal(t, "doubao-seedance-1-5-pro-251215", gjson.GetBytes(body, "model").String()) + assert.Equal(t, "dance", gjson.GetBytes(body, "prompt").String()) + assert.Equal(t, "16:9", gjson.GetBytes(body, "aspect_ratio").String()) + assert.Equal(t, "video_generation", gjson.GetBytes(body, "capability").String()) + assert.Equal(t, "none", gjson.GetBytes(body, "control_mode").String()) + assert.Equal(t, "text", gjson.GetBytes(body, "input_mode").String()) + assert.Equal(t, "720p", gjson.GetBytes(body, "resolution").String()) + assert.Equal(t, int64(5), gjson.GetBytes(body, "duration_seconds").Int()) + assert.False(t, gjson.GetBytes(body, "with_audio").Bool()) +} + +func TestBuildConfiguredTaskRequestBodyLegacySeedanceMediaProtocol(t *testing.T) { + c := newJSONTaskContext(`{ + "aspect_ratio": "16:9", + "duration_seconds": 5, + "model": "doubao-seedance-1-5-pro-251215", + "prompt": "dance", + "resolution": "720p", + "with_audio": false + }`) + req := relaycommon.TaskSubmitReq{} + require.NoError(t, common.Unmarshal([]byte(`{ + "aspect_ratio": "16:9", + "duration_seconds": 5, + "model": "doubao-seedance-1-5-pro-251215", + "prompt": "dance", + "resolution": "720p", + "with_audio": false + }`), &req)) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelBaseUrl: "https://upstream.example.com/v1", + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolLegacySeedanceMedia, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + SubmitPath: "/v1/media/generations", + QueryPath: "/v1/media/tasks/{task_id}", + }, + }, + }, + } + + reader, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + require.True(t, handled) + body := readJSONBody(t, reader) + assert.Equal(t, "doubao-seedance-1-5-pro-251215", gjson.GetBytes(body, "model").String()) + assert.Equal(t, "dance", gjson.GetBytes(body, "prompt").String()) + assert.Equal(t, "video_generation", gjson.GetBytes(body, "capability").String()) + assert.Equal(t, "text", gjson.GetBytes(body, "input_mode").String()) + assert.Equal(t, "none", gjson.GetBytes(body, "control_mode").String()) + assert.Equal(t, int64(5), gjson.GetBytes(body, "duration_seconds").Int()) + assert.False(t, gjson.GetBytes(body, "with_audio").Bool()) + assert.Equal(t, "https://upstream.example.com/v1/media/generations", BuildTaskSubmitURL(info, "https://fallback.example.com/create")) + + result, parsed, err := ParseConfiguredTaskResult([]byte(`{ + "task_id": "task_upstream_1", + "status": "succeeded", + "result": {"video_url": "https://example.com/video.mp4"} + }`), info.ChannelOtherSettings) + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "task_upstream_1", result.TaskID) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Equal(t, "https://example.com/video.mp4", result.Url) +} + +func TestBuildConfiguredTaskRequestBodyMediaGenerationPreservesInvalidRawFields(t *testing.T) { + c := newJSONTaskContext(`{ + "duration_seconds": "abc", + "model": "video-model", + "prompt": "dance", + "reference_images": {"bad": true}, + "with_audio": "not-bool" + }`) + req := relaycommon.TaskSubmitReq{Model: "video-model", Prompt: "dance"} + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: TaskRequestBodyModeMediaGeneration, + }, + }, + }, + } + + reader, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + require.True(t, handled) + body := readJSONBody(t, reader) + assert.Equal(t, "abc", gjson.GetBytes(body, "duration_seconds").String()) + assert.True(t, gjson.GetBytes(body, "reference_images.bad").Bool()) + assert.Equal(t, "not-bool", gjson.GetBytes(body, "with_audio").String()) +} + +func TestBuildConfiguredTaskRequestBodyMediaGenerationPreservesInvalidMetadataFields(t *testing.T) { + c := newJSONTaskContext(`{"model":"video-model","prompt":"dance"}`) + req := relaycommon.TaskSubmitReq{ + Model: "video-model", + Prompt: "dance", + Metadata: map[string]any{ + "duration_seconds": "abc", + "reference_images": map[string]any{"bad": true}, + "with_audio": "not-bool", + }, + } + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: TaskRequestBodyModeMediaGeneration, + }, + }, + }, + } + + reader, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + require.True(t, handled) + body := readJSONBody(t, reader) + assert.Equal(t, "abc", gjson.GetBytes(body, "duration_seconds").String()) + assert.True(t, gjson.GetBytes(body, "reference_images.bad").Bool()) + assert.Equal(t, "not-bool", gjson.GetBytes(body, "with_audio").String()) +} + +func TestBuildConfiguredTaskRequestBodyMediaGenerationKeepsExistingUpstreamModelOnEmptyPayloadModel(t *testing.T) { + c := newJSONTaskContext(`{"prompt":"dance"}`) + req := relaycommon.TaskSubmitReq{Prompt: "dance"} + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "resolved-upstream-model", + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: TaskRequestBodyModeMediaGeneration, + }, + }, + }, + } + + _, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + require.True(t, handled) + assert.Equal(t, "resolved-upstream-model", info.UpstreamModelName) +} + +func TestBuildConfiguredTaskRequestBodyFieldMapping(t *testing.T) { + c := newJSONTaskContext(`{ + "aspect_ratio": "16:9", + "duration_seconds": 0, + "metadata": { + "control_mode": "reference" + }, + "model": "local-model", + "prompt": "dance", + "resolution": "720p", + "with_audio": false + }`) + req := relaycommon.TaskSubmitReq{} + require.NoError(t, common.Unmarshal([]byte(`{ + "aspect_ratio": "16:9", + "duration_seconds": 0, + "metadata": { + "control_mode": "reference" + }, + "model": "local-model", + "prompt": "dance", + "resolution": "720p", + "with_audio": false + }`), &req)) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "upstream-model", + IsModelMapped: true, + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: TaskRequestBodyModeFieldMapping, + RequestBodyDefaults: map[string]any{ + "capability": "video_generation", + "control_mode": "none", + "input_mode": "text", + "with_audio": true, + }, + RequestBodyMapping: map[string]string{ + "aspect_ratio": "aspect_ratio", + "control_mode": "metadata.control_mode", + "duration_seconds": "duration_seconds,seconds,duration", + "model": "model", + "prompt": "prompt", + "settings.size": "resolution", + "with_audio": "with_audio", + }, + }, + }, + }, + } + + reader, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + require.True(t, handled) + body := readJSONBody(t, reader) + assert.Equal(t, "upstream-model", gjson.GetBytes(body, "model").String()) + assert.Equal(t, "dance", gjson.GetBytes(body, "prompt").String()) + assert.Equal(t, "16:9", gjson.GetBytes(body, "aspect_ratio").String()) + assert.Equal(t, "video_generation", gjson.GetBytes(body, "capability").String()) + assert.Equal(t, "reference", gjson.GetBytes(body, "control_mode").String()) + assert.Equal(t, "text", gjson.GetBytes(body, "input_mode").String()) + assert.Equal(t, "720p", gjson.GetBytes(body, "settings.size").String()) + assert.Equal(t, int64(0), gjson.GetBytes(body, "duration_seconds").Int()) + assert.False(t, gjson.GetBytes(body, "with_audio").Bool()) +} + +func TestBuildConfiguredTaskRequestBodyFieldMappingDoesNotInjectZeroDuration(t *testing.T) { + c := newJSONTaskContext(`{ + "model": "local-model", + "prompt": "dance" + }`) + req := relaycommon.TaskSubmitReq{} + require.NoError(t, common.Unmarshal([]byte(`{ + "model": "local-model", + "prompt": "dance" + }`), &req)) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: TaskRequestBodyModeFieldMapping, + RequestBodyDefaults: map[string]any{ + "duration_seconds": 9, + }, + RequestBodyMapping: map[string]string{ + "duration_seconds": "duration_seconds,seconds,duration", + "model": "model", + "prompt": "prompt", + }, + }, + }, + }, + } + + reader, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + require.True(t, handled) + body := readJSONBody(t, reader) + assert.Equal(t, int64(9), gjson.GetBytes(body, "duration_seconds").Int()) +} + +func TestBuildConfiguredTaskRequestBodyAdapterFallsBack(t *testing.T) { + c := newJSONTaskContext(`{"model":"video-model","prompt":"test"}`) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + RequestBodyMode: TaskRequestBodyModeAdapter, + }, + }, + }, + } + req := relaycommon.TaskSubmitReq{Model: "video-model", Prompt: "test"} + + reader, handled, err := BuildConfiguredTaskRequestBody(c, info, &req) + + require.NoError(t, err) + assert.False(t, handled) + assert.Nil(t, reader) +} + func TestStripTaskProtocolConfig(t *testing.T) { body := WithTaskProtocolConfig(map[string]any{ "task_id": "task_abc", @@ -145,6 +499,14 @@ func TestValidateTaskProtocolSettings(t *testing.T) { {name: "empty settings", settings: "", wantErr: false}, {name: "no task protocol config", settings: `{"azure_responses_version":"v1"}`, wantErr: false}, {name: "empty query path uses default", settings: `{"task_protocol_config":{"submit_path":"/v1/videos/create"}}`, wantErr: false}, + {name: "valid request body mode", settings: `{"task_protocol":"generic_video_task","task_protocol_config":{"request_body_mode":"pass_through","query_path":"/v1/videos/{task_id}"}}`, wantErr: false}, + {name: "valid field mapping mode", settings: `{"task_protocol":"generic_video_task","task_protocol_config":{"request_body_mode":"field_mapping","request_body_mapping":{"model":"model"},"query_path":"/v1/videos/{task_id}"}}`, wantErr: false}, + {name: "field mapping without explicit mode", settings: `{"task_protocol":"generic_video_task","task_protocol_config":{"request_body_mapping":{"model":"model"},"query_path":"/v1/videos/{task_id}"}}`, wantErr: false}, + {name: "request body mode requires protocol", settings: `{"task_protocol_config":{"request_body_mode":"pass_through","query_path":"/v1/videos/{task_id}"}}`, wantErr: true}, + {name: "request body mapping requires protocol", settings: `{"task_protocol_config":{"request_body_mapping":{"model":"model"},"query_path":"/v1/videos/{task_id}"}}`, wantErr: true}, + {name: "field mapping mode requires mapping or defaults", settings: `{"task_protocol":"generic_video_task","task_protocol_config":{"request_body_mode":"field_mapping","query_path":"/v1/videos/{task_id}"}}`, wantErr: true}, + {name: "invalid request body mode", settings: `{"task_protocol":"generic_video_task","task_protocol_config":{"request_body_mode":"unsupported","query_path":"/v1/videos/{task_id}"}}`, wantErr: true}, + {name: "unknown task protocol rejected", settings: `{"task_protocol":"unsupported_video_task","task_protocol_config":{"query_path":"/v1/videos/{task_id}"}}`, wantErr: true}, {name: "task_id placeholder", settings: `{"task_protocol_config":{"query_path":"/v1/videos/{task_id}"}}`, wantErr: false}, {name: "operation_name placeholder", settings: `{"task_protocol_config":{"query_path":"/v1beta/{operation_name}"}}`, wantErr: false}, {name: "upstream_task_id placeholder", settings: `{"task_protocol_config":{"query_path":"/v1/tasks?id={upstream_task_id}"}}`, wantErr: false}, @@ -163,6 +525,23 @@ func TestValidateTaskProtocolSettings(t *testing.T) { } } +func newJSONTaskContext(body string) *gin.Context { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/videos", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c +} + +func readJSONBody(t *testing.T, reader io.Reader) []byte { + t.Helper() + body, err := io.ReadAll(reader) + require.NoError(t, err) + require.True(t, common.IsJsonObject(string(body))) + return body +} + func TestTryHandleConfiguredSubmitResponseKlingRouteUsesKlingFormat(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() diff --git a/relay/channel/task/taskcommon/protocol_config.go b/relay/channel/task/taskcommon/protocol_config.go index b4a786e..00bcbde 100644 --- a/relay/channel/task/taskcommon/protocol_config.go +++ b/relay/channel/task/taskcommon/protocol_config.go @@ -19,6 +19,11 @@ import ( const ( TaskProtocolGenericVideo = "generic_video_task" TaskProtocolLegacySeedanceMedia = "seedance_official_media" + + TaskRequestBodyModeAdapter = "adapter" + TaskRequestBodyModePassThrough = "pass_through" + TaskRequestBodyModeFieldMapping = "field_mapping" + TaskRequestBodyModeMediaGeneration = "media_generation" ) var videoTaskChannelTypes = map[int]struct{}{ @@ -44,6 +49,25 @@ func UseConfiguredTaskProtocol(settings dto.ChannelOtherSettings) bool { return protocol == TaskProtocolGenericVideo || protocol == TaskProtocolLegacySeedanceMedia } +func UseLegacySeedanceMediaProtocol(settings dto.ChannelOtherSettings) bool { + return strings.EqualFold(strings.TrimSpace(settings.TaskProtocol), TaskProtocolLegacySeedanceMedia) +} + +func NormalizeTaskRequestBodyMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", TaskRequestBodyModeAdapter: + return TaskRequestBodyModeAdapter + case TaskRequestBodyModePassThrough: + return TaskRequestBodyModePassThrough + case TaskRequestBodyModeFieldMapping: + return TaskRequestBodyModeFieldMapping + case TaskRequestBodyModeMediaGeneration: + return TaskRequestBodyModeMediaGeneration + default: + return TaskRequestBodyModeAdapter + } +} + func IsVideoTaskChannelType(channelType int) bool { _, ok := videoTaskChannelTypes[channelType] return ok @@ -51,15 +75,15 @@ func IsVideoTaskChannelType(channelType int) bool { func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocolConfig { cfg := dto.TaskProtocolConfig{ - SubmitPath: "/v1/videos/create", - QueryPath: "/v1/videos/{task_id}", - TaskIDPath: "task_id", - StatusPath: "status", - ProgressPath: "progress", - ResultURLPaths: []string{"result.primary_url", "result.urls.0", "result.url", "result.video_url", "result.output_url", "data.result.primary_url", "data.result.urls.0", "data.result.url", "data.result.video_url", "data.result.output_url", "url", "video_url", "output_url", "file_url", "download_url", "result"}, - ErrorMessagePath: "error_message", - CreatedAtPath: "created_at", - UpdatedAtPath: "updated_at", + SubmitPath: "/v1/videos/create", + QueryPath: "/v1/videos/{task_id}", + RequestBodyMode: TaskRequestBodyModeAdapter, + TaskIDPath: "task_id", + StatusPath: "status", + ProgressPath: "progress", + ResultURLPaths: []string{"result.primary_url", "result.urls.0", "result.url", "result.video_url", "result.output_url", "data.result.primary_url", "data.result.urls.0", "data.result.url", "data.result.video_url", "data.result.output_url", "url", "video_url", "output_url", "file_url", "download_url", "result"}, + CreatedAtPath: "created_at", + UpdatedAtPath: "updated_at", StatusMap: map[string]string{ "queued": string(model.TaskStatusQueued), "pending": string(model.TaskStatusQueued), @@ -76,6 +100,7 @@ func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocol "failure": string(model.TaskStatusFailure), "error": string(model.TaskStatusFailure), }, + ErrorMessagePath: "error_message", } if input == nil { return cfg @@ -86,6 +111,18 @@ func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocol if input.QueryPath != "" { cfg.QueryPath = input.QueryPath } + if input.RequestBodyMode != "" { + cfg.RequestBodyMode = NormalizeTaskRequestBodyMode(input.RequestBodyMode) + } + if len(input.RequestBodyMapping) > 0 { + cfg.RequestBodyMapping = copyStringMap(input.RequestBodyMapping) + } + if len(input.RequestBodyDefaults) > 0 { + cfg.RequestBodyDefaults = copyAnyMap(input.RequestBodyDefaults) + } + if input.RequestBodyMode == "" && (len(cfg.RequestBodyMapping) > 0 || len(cfg.RequestBodyDefaults) > 0) { + cfg.RequestBodyMode = TaskRequestBodyModeFieldMapping + } if input.TaskIDPath != "" { cfg.TaskIDPath = input.TaskIDPath } @@ -113,11 +150,41 @@ func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocol return cfg } +func EffectiveTaskProtocolConfig(settings dto.ChannelOtherSettings) dto.TaskProtocolConfig { + cfg := NormalizeTaskProtocolConfig(settings.TaskProtocolConfig) + if UseLegacySeedanceMediaProtocol(settings) { + cfg.RequestBodyMode = TaskRequestBodyModeMediaGeneration + } + return cfg +} + +func copyStringMap(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + out := make(map[string]string, len(input)) + for k, v := range input { + out[k] = v + } + return out +} + +func copyAnyMap(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + for k, v := range input { + out[k] = v + } + return out +} + func TryHandleConfiguredSubmitResponse(c *gin.Context, responseBody []byte, info *relaycommon.RelayInfo) (string, bool, *dto.TaskError) { if info == nil || info.ChannelMeta == nil || !UseConfiguredTaskProtocol(info.ChannelOtherSettings) { return "", false, nil } - cfg := NormalizeTaskProtocolConfig(info.ChannelOtherSettings.TaskProtocolConfig) + cfg := EffectiveTaskProtocolConfig(info.ChannelOtherSettings) taskID := StringFromGJSONPath(responseBody, cfg.TaskIDPath) if taskID == "" { taskID = StringFromGJSONPath(responseBody, "id") @@ -158,7 +225,7 @@ func ParseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting if !UseConfiguredTaskProtocol(settings) { return nil, false, nil } - cfg := NormalizeTaskProtocolConfig(settings.TaskProtocolConfig) + cfg := EffectiveTaskProtocolConfig(settings) taskID := StringFromGJSONPath(respBody, cfg.TaskIDPath) statusRaw := StringFromGJSONPath(respBody, cfg.StatusPath) progressRaw := StringFromGJSONPath(respBody, cfg.ProgressPath) @@ -226,7 +293,7 @@ func TaskProtocolConfigFromTask(task *model.Task) (dto.TaskProtocolConfig, bool) if !UseConfiguredTaskProtocol(settings) { return dto.TaskProtocolConfig{}, false } - return NormalizeTaskProtocolConfig(settings.TaskProtocolConfig), true + return EffectiveTaskProtocolConfig(settings), true } func StringFromGJSONPath(data []byte, path string) string { diff --git a/relay/channel/task/taskcommon/request_body.go b/relay/channel/task/taskcommon/request_body.go new file mode 100644 index 0000000..2ca683b --- /dev/null +++ b/relay/channel/task/taskcommon/request_body.go @@ -0,0 +1,581 @@ +package taskcommon + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + + "github.com/gin-gonic/gin" +) + +type configuredMediaRequest struct { + Model string + Prompt string + AspectRatio string + Capability string + ControlMode string + DurationSeconds *int + EndImage string + Image string + InputMode string + ReferenceImages []string + Resolution string + WithAudio *bool + Extra map[string]any +} + +func (r *configuredMediaRequest) ToMap() map[string]any { + out := map[string]any{} + for k, v := range r.Extra { + out[k] = v + } + setString(out, "model", r.Model) + setString(out, "prompt", r.Prompt) + setString(out, "aspect_ratio", r.AspectRatio) + setString(out, "capability", r.Capability) + setString(out, "control_mode", r.ControlMode) + setString(out, "end_image", r.EndImage) + setString(out, "image", r.Image) + setString(out, "input_mode", r.InputMode) + setString(out, "resolution", r.Resolution) + if r.DurationSeconds != nil { + out["duration_seconds"] = *r.DurationSeconds + } + if len(r.ReferenceImages) > 0 { + out["reference_images"] = r.ReferenceImages + } + if r.WithAudio != nil { + out["with_audio"] = *r.WithAudio + } + return out +} + +func BuildConfiguredTaskRequestBody(c *gin.Context, info *relaycommon.RelayInfo, req *relaycommon.TaskSubmitReq) (io.Reader, bool, error) { + if info == nil || info.ChannelMeta == nil || !UseConfiguredTaskProtocol(info.ChannelOtherSettings) { + return nil, false, nil + } + cfg := EffectiveTaskProtocolConfig(info.ChannelOtherSettings) + switch cfg.RequestBodyMode { + case TaskRequestBodyModeAdapter: + return nil, false, nil + case TaskRequestBodyModePassThrough: + body, err := buildPassThroughTaskBody(c, info) + return body, true, err + case TaskRequestBodyModeFieldMapping: + body, err := buildFieldMappingTaskBody(c, info, req, cfg.RequestBodyMapping, cfg.RequestBodyDefaults) + return body, true, err + case TaskRequestBodyModeMediaGeneration: + taskReq, err := configuredTaskRequest(c, req) + if err != nil { + return nil, true, err + } + body, err := buildMediaGenerationTaskBody(c, info, taskReq) + return body, true, err + default: + return nil, false, fmt.Errorf("unsupported task request body mode: %s", cfg.RequestBodyMode) + } +} + +func configuredTaskRequest(c *gin.Context, req *relaycommon.TaskSubmitReq) (*relaycommon.TaskSubmitReq, error) { + if req != nil { + return req, nil + } + var parsed relaycommon.TaskSubmitReq + if err := common.UnmarshalBodyReusable(c, &parsed); err != nil { + return nil, err + } + return &parsed, nil +} + +func buildPassThroughTaskBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + var raw map[string]any + if err := common.UnmarshalBodyReusable(c, &raw); err != nil { + return nil, err + } + applyConfiguredModel(raw, info) + data, err := common.Marshal(raw) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func buildFieldMappingTaskBody(c *gin.Context, info *relaycommon.RelayInfo, req *relaycommon.TaskSubmitReq, mapping map[string]string, defaults map[string]any) (io.Reader, error) { + source, err := configuredTaskSource(c, req) + if err != nil { + return nil, err + } + applyConfiguredModel(source, info) + + out := cloneDefaultRequestBody(defaults) + for targetPath, sourcePaths := range mapping { + value, exists := firstMappedSourceValue(source, sourcePaths) + if !exists { + continue + } + setMappedOutputValue(out, targetPath, value) + } + if len(out) == 0 { + return nil, fmt.Errorf("configured task request body mapping produced an empty body") + } + + data, err := common.Marshal(out) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func buildMediaGenerationTaskBody(c *gin.Context, info *relaycommon.RelayInfo, req *relaycommon.TaskSubmitReq) (io.Reader, error) { + if req == nil { + return nil, fmt.Errorf("request is nil") + } + payload := newConfiguredMediaRequest(req) + var raw map[string]any + if err := common.UnmarshalBodyReusable(c, &raw); err == nil { + applyMediaRawFields(payload, raw) + } + inferMediaModes(payload) + applyConfiguredMediaModel(payload, info) + data, err := common.Marshal(payload.ToMap()) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func configuredTaskSource(c *gin.Context, req *relaycommon.TaskSubmitReq) (map[string]any, error) { + source := map[string]any{} + var raw map[string]any + err := common.UnmarshalBodyReusable(c, &raw) + if err != nil && req == nil { + return nil, err + } + for k, v := range raw { + source[k] = v + } + if req != nil { + enrichTaskSource(source, req) + } + return source, nil +} + +func enrichTaskSource(source map[string]any, req *relaycommon.TaskSubmitReq) { + if source == nil || req == nil { + return + } + setSourceIfMissing(source, "prompt", req.Prompt) + setSourceIfMissing(source, "model", req.Model) + setSourceIfMissing(source, "mode", req.Mode) + setSourceIfMissing(source, "image", firstNonEmpty(req.Image, firstString(req.Images))) + setSourceIfMissing(source, "images", req.Images) + setSourceIfMissing(source, "size", req.Size) + if req.Duration != 0 { + setSourceIfMissing(source, "duration", req.Duration) + } + setSourceIfMissing(source, "seconds", req.Seconds) + setSourceIfMissing(source, "input_reference", req.InputReference) + setSourceIfMissing(source, "aspect_ratio", req.AspectRatio) + setSourceIfMissing(source, "capability", req.Capability) + setSourceIfMissing(source, "control_mode", req.ControlMode) + if req.DurationSeconds != nil { + setSourceIfMissing(source, "duration_seconds", *req.DurationSeconds) + } else if sec, ok := parsePositiveOrAutoSeconds(req.Seconds); ok { + setSourceIfMissing(source, "duration_seconds", sec) + } else if req.Duration != 0 { + setSourceIfMissing(source, "duration_seconds", req.Duration) + } + setSourceIfMissing(source, "end_image", req.EndImage) + setSourceIfMissing(source, "input_mode", req.InputMode) + setSourceIfMissing(source, "reference_images", req.ReferenceImages) + setSourceIfMissing(source, "resolution", req.Resolution) + if req.WithAudio != nil { + setSourceIfMissing(source, "with_audio", *req.WithAudio) + } + if len(req.Metadata) > 0 { + setSourceIfMissing(source, "metadata", req.Metadata) + } +} + +func setSourceIfMissing(source map[string]any, key string, value any) { + if source == nil || key == "" { + return + } + if _, exists := source[key]; exists || isEmptySourceValue(value) { + return + } + source[key] = value +} + +func isEmptySourceValue(value any) bool { + switch v := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(v) == "" + case []string: + return len(v) == 0 + case []any: + return len(v) == 0 + default: + return false + } +} + +func cloneDefaultRequestBody(defaults map[string]any) map[string]any { + out := map[string]any{} + if len(defaults) == 0 { + return out + } + data, err := common.Marshal(defaults) + if err != nil { + for k, v := range defaults { + out[k] = v + } + return out + } + if err := common.Unmarshal(data, &out); err != nil { + for k, v := range defaults { + out[k] = v + } + } + return out +} + +func firstMappedSourceValue(source map[string]any, sourcePaths string) (any, bool) { + for _, path := range strings.Split(sourcePaths, ",") { + if value, ok := mappedPathValue(source, strings.TrimSpace(path)); ok { + return value, true + } + } + return nil, false +} + +func mappedPathValue(source any, path string) (any, bool) { + if strings.TrimSpace(path) == "" { + return nil, false + } + current := source + for _, part := range strings.Split(path, ".") { + part = strings.TrimSpace(part) + if part == "" { + return nil, false + } + switch typed := current.(type) { + case map[string]any: + value, ok := typed[part] + if !ok { + return nil, false + } + current = value + case []any: + index, err := strconv.Atoi(part) + if err != nil || index < 0 || index >= len(typed) { + return nil, false + } + current = typed[index] + default: + return nil, false + } + } + return current, true +} + +func setMappedOutputValue(out map[string]any, path string, value any) { + path = strings.TrimSpace(path) + if out == nil || path == "" { + return + } + parts := strings.Split(path, ".") + current := out + for _, part := range parts[:len(parts)-1] { + part = strings.TrimSpace(part) + if part == "" { + return + } + next, ok := current[part].(map[string]any) + if !ok { + next = map[string]any{} + current[part] = next + } + current = next + } + leaf := strings.TrimSpace(parts[len(parts)-1]) + if leaf == "" { + return + } + current[leaf] = value +} + +func newConfiguredMediaRequest(req *relaycommon.TaskSubmitReq) *configuredMediaRequest { + payload := &configuredMediaRequest{ + Model: req.Model, + Prompt: req.Prompt, + AspectRatio: req.AspectRatio, + Capability: firstNonEmpty(req.Capability, "video_generation"), + ControlMode: req.ControlMode, + DurationSeconds: req.DurationSeconds, + EndImage: req.EndImage, + Image: firstNonEmpty(req.Image, firstString(req.Images)), + InputMode: req.InputMode, + ReferenceImages: append([]string{}, req.ReferenceImages...), + Resolution: req.Resolution, + WithAudio: req.WithAudio, + Extra: map[string]any{}, + } + if payload.DurationSeconds == nil { + if sec, ok := parsePositiveOrAutoSeconds(req.Seconds); ok { + payload.DurationSeconds = &sec + } else if req.Duration != 0 { + sec := req.Duration + payload.DurationSeconds = &sec + } + } + applyMediaMetadata(payload, req.Metadata) + inferMediaModes(payload) + return payload +} + +func applyConfiguredModel(raw map[string]any, info *relaycommon.RelayInfo) { + if raw == nil || info == nil { + return + } + if info.IsModelMapped { + raw["model"] = info.UpstreamModelName + return + } + if model, ok := raw["model"].(string); ok && strings.TrimSpace(model) != "" { + info.UpstreamModelName = strings.TrimSpace(model) + } +} + +func applyConfiguredMediaModel(payload *configuredMediaRequest, info *relaycommon.RelayInfo) { + if payload == nil || info == nil { + return + } + if info.IsModelMapped { + payload.Model = info.UpstreamModelName + return + } + if model := strings.TrimSpace(payload.Model); model != "" { + info.UpstreamModelName = model + } +} + +func applyMediaRawFields(payload *configuredMediaRequest, raw map[string]any) { + if payload == nil || raw == nil { + return + } + setPayloadString(raw, "model", &payload.Model) + setPayloadString(raw, "prompt", &payload.Prompt) + setPayloadString(raw, "aspect_ratio", &payload.AspectRatio) + setPayloadString(raw, "capability", &payload.Capability) + setPayloadString(raw, "control_mode", &payload.ControlMode) + setPayloadString(raw, "end_image", &payload.EndImage) + setPayloadString(raw, "image", &payload.Image) + setPayloadString(raw, "input_mode", &payload.InputMode) + setPayloadString(raw, "resolution", &payload.Resolution) + if v, ok := raw["duration_seconds"]; ok { + if sec, exists := intFromAny(v); exists { + payload.DurationSeconds = &sec + } else { + setPayloadExtra(payload, "duration_seconds", v) + } + } + if v, ok := raw["with_audio"]; ok { + if b, exists := boolFromAny(v); exists { + payload.WithAudio = &b + } else { + setPayloadExtra(payload, "with_audio", v) + } + } + if v, ok := raw["reference_images"]; ok { + if refs, exists := stringSliceFromAny(v); exists { + payload.ReferenceImages = refs + } else { + setPayloadExtra(payload, "reference_images", v) + } + } +} + +func applyMediaMetadata(payload *configuredMediaRequest, metadata map[string]any) { + if payload == nil || metadata == nil { + return + } + for k, v := range metadata { + switch k { + case "model": + setStringValue(v, &payload.Model) + case "prompt": + setStringValue(v, &payload.Prompt) + case "aspect_ratio", "ratio": + setStringValue(v, &payload.AspectRatio) + case "capability": + setStringValue(v, &payload.Capability) + case "control_mode": + setStringValue(v, &payload.ControlMode) + case "duration_seconds": + if sec, exists := intFromAny(v); exists { + payload.DurationSeconds = &sec + } else { + setPayloadExtra(payload, k, v) + } + case "end_image", "image_tail", "tail_image": + setStringValue(v, &payload.EndImage) + case "image": + setStringValue(v, &payload.Image) + case "input_mode": + setStringValue(v, &payload.InputMode) + case "reference_images": + if refs, exists := stringSliceFromAny(v); exists { + payload.ReferenceImages = refs + } else { + setPayloadExtra(payload, k, v) + } + case "resolution": + setStringValue(v, &payload.Resolution) + case "with_audio", "generate_audio": + if b, exists := boolFromAny(v); exists { + payload.WithAudio = &b + } else { + setPayloadExtra(payload, k, v) + } + default: + payload.Extra[k] = v + } + } +} + +func inferMediaModes(payload *configuredMediaRequest) { + if payload == nil { + return + } + if payload.Capability == "" { + payload.Capability = "video_generation" + } + if payload.InputMode == "" { + switch { + case len(payload.ReferenceImages) > 0: + payload.InputMode = "multi_image" + case payload.Image != "": + payload.InputMode = "single_image" + default: + payload.InputMode = "text" + } + } + if payload.ControlMode == "" { + switch { + case payload.InputMode == "multi_image": + payload.ControlMode = "reference" + case payload.EndImage != "": + payload.ControlMode = "end_frame" + default: + payload.ControlMode = "none" + } + } +} + +func parsePositiveOrAutoSeconds(value string) (int, bool) { + if strings.TrimSpace(value) == "" { + return 0, false + } + sec, err := strconv.Atoi(value) + if err != nil { + return 0, false + } + return sec, sec > 0 || sec == -1 +} + +func intFromAny(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int64: + return int(v), true + case float64: + return int(v), true + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + return n, err == nil + default: + return 0, false + } +} + +func boolFromAny(value any) (bool, bool) { + switch v := value.(type) { + case bool: + return v, true + case string: + b, err := strconv.ParseBool(strings.TrimSpace(v)) + return b, err == nil + default: + return false, false + } +} + +func stringSliceFromAny(value any) ([]string, bool) { + switch v := value.(type) { + case []string: + return append([]string{}, v...), true + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + s := strings.TrimSpace(common.Interface2String(item)) + if s != "" { + out = append(out, s) + } + } + return out, len(out) > 0 + default: + return nil, false + } +} + +func setPayloadString(raw map[string]any, key string, target *string) { + if raw == nil || target == nil { + return + } + if value, ok := raw[key]; ok { + setStringValue(value, target) + } +} + +func setPayloadExtra(payload *configuredMediaRequest, key string, value any) { + if payload == nil || strings.TrimSpace(key) == "" { + return + } + if payload.Extra == nil { + payload.Extra = map[string]any{} + } + payload.Extra[key] = value +} + +func setStringValue(value any, target *string) { + if target == nil { + return + } + if s, ok := value.(string); ok { + *target = strings.TrimSpace(s) + } +} + +func setString(out map[string]any, key string, value string) { + if strings.TrimSpace(value) != "" { + out[key] = value + } +} + +func firstString(values []string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/relay/relay_task.go b/relay/relay_task.go index 1eec2ea..f812d4e 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -229,7 +229,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } // 8. 构建请求体 - requestBody, err := adaptor.BuildRequestBody(c, info) + requestBody, err := buildTaskSubmitRequestBody(c, info, adaptor) if err != nil { return nil, service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) } @@ -278,6 +278,22 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe }, nil } +func buildTaskSubmitRequestBody(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor) (io.Reader, error) { + var taskReq *relaycommon.TaskSubmitReq + if req, err := relaycommon.GetTaskRequest(c); err == nil { + taskReq = &req + } + + requestBody, handled, err := taskcommon.BuildConfiguredTaskRequestBody(c, info, taskReq) + if err != nil { + return nil, err + } + if handled { + return requestBody, nil + } + return adaptor.BuildRequestBody(c, info) +} + // recalcQuotaFromRatios 根据 adjustedRatios 重新计算 quota。 // 公式: baseQuota × ∏(ratio) — 其中 baseQuota 是不含 OtherRatios 的基础额度。 func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float64) int { diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 72f276b..7dec762 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -229,6 +229,8 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.claude_beta_query || values.video_task_path_override_enabled || values.video_task_protocol_enabled || + values.video_task_request_body_mapping?.trim() || + values.video_task_request_body_defaults?.trim() || values.upstream_model_update_check_enabled || values.upstream_model_update_auto_sync_enabled || values.upstream_model_update_ignored_models?.trim() @@ -393,6 +395,7 @@ export function ChannelMutateDrawer({ 'video_task_path_override_enabled' ) const videoTaskProtocolEnabled = form.watch('video_task_protocol_enabled') + const videoTaskRequestBodyMode = form.watch('video_task_request_body_mode') const currentAdvancedCustom = form.watch('advanced_custom') const formSnapshot = form.watch() as ChannelFormValues const showVideoTaskPathFields = @@ -3080,6 +3083,109 @@ export function ChannelMutateDrawer({ )} {isVideoTaskChannel && videoTaskProtocolEnabled && (
+ } + /> + + ( + + {t('Request Body Mode')} + + + {t( + 'Choose how the generic task protocol builds the upstream request body' + )} + + + + )} + /> + + {videoTaskRequestBodyMode === 'field_mapping' && ( +
+ ( + + + {t('Request Body Field Mapping')} + + +