diff --git a/constant/context_key.go b/constant/context_key.go index ccb8010f9476..632ff148aefc 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -7,8 +7,12 @@ const ( ContextKeyPromptTokens ContextKey = "prompt_tokens" ContextKeyEstimatedTokens ContextKey = "estimated_tokens" - ContextKeyOriginalModel ContextKey = "original_model" - ContextKeyRequestStartTime ContextKey = "request_start_time" + ContextKeyOriginalModel ContextKey = "original_model" + ContextKeyRequestStartTime ContextKey = "request_start_time" + ContextKeyUpstreamResourceModel ContextKey = "upstream_resource_model" + ContextKeyUpstreamResourceChannelId ContextKey = "upstream_resource_channel_id" + ContextKeyUpstreamResourceKeyIndex ContextKey = "upstream_resource_key_index" + ContextKeyUpstreamResourceKeyFingerprint ContextKey = "upstream_resource_key_fingerprint" /* token related keys */ ContextKeyTokenUnlimited ContextKey = "token_unlimited_quota" diff --git a/controller/openai_upstream_resource.go b/controller/openai_upstream_resource.go new file mode 100644 index 000000000000..50d12d8f5b84 --- /dev/null +++ b/controller/openai_upstream_resource.go @@ -0,0 +1,211 @@ +package controller + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayhelper "github.com/QuantumNous/new-api/relay/helper" + "github.com/gin-gonic/gin" +) + +type openAIFileResourceResponse struct { + Id string `json:"id"` +} + +type openAIBatchResourceResponse struct { + Id string `json:"id"` + OutputFileId string `json:"output_file_id"` + ErrorFileId string `json:"error_file_id"` +} + +var hopByHopResponseHeaders = map[string]struct{}{ + "connection": {}, + "keep-alive": {}, + "proxy-authenticate": {}, + "proxy-authorization": {}, + "te": {}, + "trailer": {}, + "transfer-encoding": {}, + "upgrade": {}, +} + +func openAIUpstreamResourceError(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{ + "error": gin.H{ + "message": message, + "type": "new_api_error", + "code": "openai_upstream_resource_error", + }, + }) +} + +func copyOpenAIUpstreamResponseHeaders(dst http.Header, src http.Header) { + skippedHeaders := make(map[string]struct{}, len(hopByHopResponseHeaders)) + for name := range hopByHopResponseHeaders { + skippedHeaders[name] = struct{}{} + } + for _, connectionValue := range src.Values("Connection") { + for _, name := range strings.Split(connectionValue, ",") { + if name = strings.ToLower(strings.TrimSpace(name)); name != "" { + skippedHeaders[name] = struct{}{} + } + } + } + for name, values := range src { + if _, skip := skippedHeaders[strings.ToLower(name)]; skip { + continue + } + for _, value := range values { + dst.Add(name, value) + } + } +} + +func bindOpenAIUpstreamResourceResponse(c *gin.Context, body []byte) error { + userId := common.GetContextKeyInt(c, constant.ContextKeyUserId) + channelId := common.GetContextKeyInt(c, constant.ContextKeyChannelId) + modelName := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + channelKeyIndex := common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex) + channelKeyFingerprint := model.ChannelKeyFingerprint(common.GetContextKeyString(c, constant.ContextKeyChannelKey)) + path := c.Request.URL.Path + + resources := make([]model.OpenAIUpstreamResource, 0, 3) + if c.Request.Method == http.MethodPost && path == "/v1/files" { + var response openAIFileResourceResponse + if err := common.Unmarshal(body, &response); err != nil { + return fmt.Errorf("invalid upstream file response: %w", err) + } + if strings.TrimSpace(response.Id) == "" { + return fmt.Errorf("invalid upstream file response: id is required") + } + resources = append(resources, model.OpenAIUpstreamResource{ + UserId: userId, + ChannelId: channelId, + ChannelKeyIndex: channelKeyIndex, + ChannelKeyFingerprint: channelKeyFingerprint, + ResourceType: model.OpenAIUpstreamResourceTypeFile, + ResourceId: response.Id, + Model: modelName, + }) + } else if strings.HasPrefix(path, "/v1/batches") { + var response openAIBatchResourceResponse + if err := common.Unmarshal(body, &response); err != nil { + return fmt.Errorf("invalid upstream batch response: %w", err) + } + if strings.TrimSpace(response.Id) == "" { + return fmt.Errorf("invalid upstream batch response: id is required") + } + resources = append(resources, model.OpenAIUpstreamResource{ + UserId: userId, + ChannelId: channelId, + ChannelKeyIndex: channelKeyIndex, + ChannelKeyFingerprint: channelKeyFingerprint, + ResourceType: model.OpenAIUpstreamResourceTypeBatch, + ResourceId: response.Id, + Model: modelName, + }) + for _, fileId := range []string{response.OutputFileId, response.ErrorFileId} { + if strings.TrimSpace(fileId) == "" { + continue + } + resources = append(resources, model.OpenAIUpstreamResource{ + UserId: userId, + ChannelId: channelId, + ChannelKeyIndex: channelKeyIndex, + ChannelKeyFingerprint: channelKeyFingerprint, + ResourceType: model.OpenAIUpstreamResourceTypeFile, + ResourceId: fileId, + Model: modelName, + }) + } + } + return model.SaveOpenAIUpstreamResources(resources) +} + +// RelayOpenAIUpstreamResource proxies native OpenAI File and Batch APIs. +// Batch execution is owned by the upstream provider; this path intentionally +// does not estimate or settle quota because upstream batch usage is asynchronous. +func RelayOpenAIUpstreamResource(c *gin.Context) { + info := relaycommon.GenRelayInfoOpenAI(c, nil) + info.InitChannelMeta(c) + if c.Request.Method == http.MethodPost && c.Request.URL.Path == "/v1/files" { + if err := relayhelper.ModelMappedHelper(c, info, nil); err != nil { + openAIUpstreamResourceError(c, http.StatusBadRequest, "invalid channel model mapping") + return + } + if info.IsModelMapped && info.UpstreamModelName != info.OriginModelName { + openAIUpstreamResourceError(c, http.StatusBadRequest, "Batch uploads do not support channel model mapping") + return + } + } + adaptor := relay.GetAdaptor(info.ApiType) + if adaptor == nil { + openAIUpstreamResourceError(c, http.StatusInternalServerError, "selected channel does not support OpenAI-compatible relay") + return + } + adaptor.Init(info) + + var requestBody io.Reader = http.NoBody + if c.Request.Method == http.MethodPost { + storage, err := common.GetBodyStorage(c) + if err != nil { + openAIUpstreamResourceError(c, http.StatusBadRequest, "failed to read request body") + return + } + requestBody = common.NewReplayableBodyReader(storage) + } + + upstreamResponse, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + openAIUpstreamResourceError(c, http.StatusBadGateway, "upstream request failed") + return + } + response, ok := upstreamResponse.(*http.Response) + if !ok || response == nil { + openAIUpstreamResourceError(c, http.StatusBadGateway, "upstream returned an unsupported response") + return + } + defer response.Body.Close() + isTerminalDeleteStatus := response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices || + response.StatusCode == http.StatusNotFound + if isTerminalDeleteStatus && c.Request.Method == http.MethodDelete && + strings.HasPrefix(c.Request.URL.Path, "/v1/files/") { + if deleteErr := model.DeleteOpenAIUpstreamResource( + common.GetContextKeyInt(c, constant.ContextKeyUserId), + model.OpenAIUpstreamResourceTypeFile, + c.Param("id"), + ); deleteErr != nil { + openAIUpstreamResourceError(c, http.StatusBadGateway, "failed to delete upstream resource binding") + return + } + } + + shouldBind := response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && + ((c.Request.Method == http.MethodPost && c.Request.URL.Path == "/v1/files") || strings.HasPrefix(c.Request.URL.Path, "/v1/batches")) + if shouldBind { + body, readErr := io.ReadAll(response.Body) + if readErr != nil { + openAIUpstreamResourceError(c, http.StatusBadGateway, "failed to read upstream response") + return + } + if bindErr := bindOpenAIUpstreamResourceResponse(c, body); bindErr != nil { + openAIUpstreamResourceError(c, http.StatusBadGateway, "failed to persist upstream resource binding") + return + } + copyOpenAIUpstreamResponseHeaders(c.Writer.Header(), response.Header) + c.Status(response.StatusCode) + _, _ = c.Writer.Write(body) + return + } + + copyOpenAIUpstreamResponseHeaders(c.Writer.Header(), response.Header) + c.Status(response.StatusCode) + _, _ = io.Copy(c.Writer, response.Body) +} diff --git a/controller/openai_upstream_resource_test.go b/controller/openai_upstream_resource_test.go new file mode 100644 index 000000000000..80bcfe12ca81 --- /dev/null +++ b/controller/openai_upstream_resource_test.go @@ -0,0 +1,288 @@ +package controller + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupOpenAIUpstreamResourceControllerTest(t *testing.T) { + t.Helper() + previousDB := model.DB + previousType := common.MainDatabaseType() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.OpenAIUpstreamResource{})) + model.DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + t.Cleanup(func() { + model.DB = previousDB + common.SetMainDatabaseType(previousType) + }) +} + +func setOpenAIUpstreamResourceContext(c *gin.Context, baseURL string) { + common.SetContextKey(c, constant.ContextKeyUserId, 101) + common.SetContextKey(c, constant.ContextKeyChannelId, 71) + common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeOpenAI) + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, baseURL) + common.SetContextKey(c, constant.ContextKeyChannelKey, "upstream-key") + common.SetContextKey(c, constant.ContextKeyChannelSetting, dto.ChannelSettings{}) + common.SetContextKey(c, constant.ContextKeyOriginalModel, "gpt-image-2") +} + +func TestRelayOpenAIUpstreamResourcePreservesRequestAndBindsUploadedFile(t *testing.T) { + setupOpenAIUpstreamResourceControllerTest(t) + type observedRequest struct { + Method string + RequestURI string + ContentType string + Auth string + Body string + } + observed := make(chan observedRequest, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + observed <- observedRequest{ + Method: r.Method, + RequestURI: r.URL.RequestURI(), + ContentType: r.Header.Get("Content-Type"), + Auth: r.Header.Get("Authorization"), + Body: string(body), + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Upstream-Request", "req_123") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"file_123","object":"file","purpose":"batch"}`)) + })) + defer upstream.Close() + + body := "multipart-body-is-preserved" + router := gin.New() + router.POST("/v1/files", func(c *gin.Context) { + setOpenAIUpstreamResourceContext(c, upstream.URL) + }, RelayOpenAIUpstreamResource) + request := httptest.NewRequest(http.MethodPost, "/v1/files?trace=1", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "multipart/form-data; boundary=test-boundary") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + require.Equal(t, http.StatusCreated, response.Code, response.Body.String()) + assert.JSONEq(t, `{"id":"file_123","object":"file","purpose":"batch"}`, response.Body.String()) + assert.Equal(t, "req_123", response.Header().Get("X-Upstream-Request")) + + gotRequest := <-observed + assert.Equal(t, http.MethodPost, gotRequest.Method) + assert.Equal(t, "/v1/files?trace=1", gotRequest.RequestURI) + assert.Equal(t, "multipart/form-data; boundary=test-boundary", gotRequest.ContentType) + assert.Equal(t, "Bearer upstream-key", gotRequest.Auth) + assert.Equal(t, body, gotRequest.Body) + + resource, found, err := model.GetOpenAIUpstreamResource(101, model.OpenAIUpstreamResourceTypeFile, "file_123") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, 71, resource.ChannelId) + assert.Equal(t, "gpt-image-2", resource.Model) + assert.Equal(t, model.ChannelKeyFingerprint("upstream-key"), resource.ChannelKeyFingerprint) +} + +func TestRelayOpenAIUpstreamResourceBindsBatchOutputFiles(t *testing.T) { + setupOpenAIUpstreamResourceControllerTest(t) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/batches", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"batch_123", + "input_file_id":"file_input", + "output_file_id":"file_output", + "error_file_id":"file_error", + "status":"completed" + }`)) + })) + defer upstream.Close() + + router := gin.New() + router.POST("/v1/batches", func(c *gin.Context) { + setOpenAIUpstreamResourceContext(c, upstream.URL) + }, RelayOpenAIUpstreamResource) + request := httptest.NewRequest(http.MethodPost, "/v1/batches", bytes.NewBufferString(`{"input_file_id":"file_input"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + for resourceType, resourceID := range map[string]string{ + model.OpenAIUpstreamResourceTypeBatch: "batch_123", + model.OpenAIUpstreamResourceTypeFile: "file_output", + } { + resource, found, err := model.GetOpenAIUpstreamResource(101, resourceType, resourceID) + require.NoError(t, err) + require.True(t, found, resourceID) + assert.Equal(t, 71, resource.ChannelId) + assert.Equal(t, "gpt-image-2", resource.Model) + } + _, found, err := model.GetOpenAIUpstreamResource(101, model.OpenAIUpstreamResourceTypeFile, "file_error") + require.NoError(t, err) + assert.True(t, found) +} + +func TestRelayOpenAIUpstreamResourcePreservesUpstreamError(t *testing.T) { + setupOpenAIUpstreamResourceControllerTest(t) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Add("X-Upstream-Debug", "first") + w.Header().Add("X-Upstream-Debug", "second") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"error":{"message":"invalid batch"}}`)) + })) + defer upstream.Close() + + router := gin.New() + router.GET("/v1/batches/:id", func(c *gin.Context) { + setOpenAIUpstreamResourceContext(c, upstream.URL) + }, RelayOpenAIUpstreamResource) + request := httptest.NewRequest(http.MethodGet, "/v1/batches/batch_bad?include=errors", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusUnprocessableEntity, response.Code) + assert.JSONEq(t, `{"error":{"message":"invalid batch"}}`, response.Body.String()) + assert.Equal(t, []string{"first", "second"}, response.Header().Values("X-Upstream-Debug")) +} + +func TestRelayOpenAIUpstreamResourceDeletesFileBindingAfterUpstreamSuccess(t *testing.T) { + setupOpenAIUpstreamResourceControllerTest(t) + require.NoError(t, model.SaveOpenAIUpstreamResources([]model.OpenAIUpstreamResource{{ + UserId: 101, + ChannelId: 71, + ChannelKeyFingerprint: model.ChannelKeyFingerprint("upstream-key"), + ResourceType: model.OpenAIUpstreamResourceTypeFile, + ResourceId: "file_delete", + Model: "gpt-image-2", + }})) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/v1/files/file_delete", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"file_delete","deleted":true}`)) + })) + defer upstream.Close() + + router := gin.New() + router.DELETE("/v1/files/:id", func(c *gin.Context) { + setOpenAIUpstreamResourceContext(c, upstream.URL) + }, RelayOpenAIUpstreamResource) + request := httptest.NewRequest(http.MethodDelete, "/v1/files/file_delete", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + _, found, err := model.GetOpenAIUpstreamResource(101, model.OpenAIUpstreamResourceTypeFile, "file_delete") + require.NoError(t, err) + assert.False(t, found) +} + +func TestRelayOpenAIUpstreamResourceDeleteRetryCleansBindingAfterUpstreamNotFound(t *testing.T) { + setupOpenAIUpstreamResourceControllerTest(t) + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if upstreamCalls.Add(1) == 1 { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"file_retry","deleted":true}`)) + return + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"message":"file not found"}}`)) + })) + defer upstream.Close() + + router := gin.New() + router.DELETE("/v1/files/:id", func(c *gin.Context) { + setOpenAIUpstreamResourceContext(c, upstream.URL) + }, RelayOpenAIUpstreamResource) + + require.NoError(t, model.DB.Migrator().DropTable(&model.OpenAIUpstreamResource{})) + firstRequest := httptest.NewRequest(http.MethodDelete, "/v1/files/file_retry", nil) + firstResponse := httptest.NewRecorder() + router.ServeHTTP(firstResponse, firstRequest) + require.Equal(t, http.StatusBadGateway, firstResponse.Code, firstResponse.Body.String()) + + require.NoError(t, model.DB.AutoMigrate(&model.OpenAIUpstreamResource{})) + require.NoError(t, model.SaveOpenAIUpstreamResources([]model.OpenAIUpstreamResource{{ + UserId: 101, + ChannelId: 71, + ChannelKeyFingerprint: model.ChannelKeyFingerprint("upstream-key"), + ResourceType: model.OpenAIUpstreamResourceTypeFile, + ResourceId: "file_retry", + Model: "gpt-image-2", + }})) + + secondRequest := httptest.NewRequest(http.MethodDelete, "/v1/files/file_retry", nil) + secondResponse := httptest.NewRecorder() + router.ServeHTTP(secondResponse, secondRequest) + require.Equal(t, http.StatusNotFound, secondResponse.Code, secondResponse.Body.String()) + assert.JSONEq(t, `{"error":{"message":"file not found"}}`, secondResponse.Body.String()) + + _, found, err := model.GetOpenAIUpstreamResource(101, model.OpenAIUpstreamResourceTypeFile, "file_retry") + require.NoError(t, err) + assert.False(t, found) + assert.Equal(t, int32(2), upstreamCalls.Load()) +} + +func TestRelayOpenAIUpstreamResourceRejectsModelMappingBeforeUpload(t *testing.T) { + setupOpenAIUpstreamResourceControllerTest(t) + var upstreamCalled atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamCalled.Store(true) + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + + router := gin.New() + router.POST("/v1/files", func(c *gin.Context) { + setOpenAIUpstreamResourceContext(c, upstream.URL) + common.SetContextKey(c, constant.ContextKeyChannelModelMapping, `{"gpt-image-2":"provider-image-model"}`) + }, RelayOpenAIUpstreamResource) + request := httptest.NewRequest(http.MethodPost, "/v1/files", bytes.NewBufferString("multipart-body")) + request.Header.Set("Content-Type", "multipart/form-data; boundary=test") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusBadRequest, response.Code) + assert.False(t, upstreamCalled.Load()) +} + +func TestCopyOpenAIUpstreamResponseHeadersRemovesConnectionScopedHeaders(t *testing.T) { + source := http.Header{ + "Connection": []string{"keep-alive, X-Internal-Hop"}, + "Keep-Alive": []string{"timeout=5"}, + "X-Internal-Hop": []string{"secret"}, + "X-Upstream-Result": []string{"one", "two"}, + } + destination := make(http.Header) + + copyOpenAIUpstreamResponseHeaders(destination, source) + + assert.Empty(t, destination.Values("Connection")) + assert.Empty(t, destination.Values("Keep-Alive")) + assert.Empty(t, destination.Values("X-Internal-Hop")) + assert.Equal(t, []string{"one", "two"}, destination.Values("X-Upstream-Result")) +} diff --git a/middleware/distributor.go b/middleware/distributor.go index 7decf0e28728..b7b395bc6077 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -34,6 +34,7 @@ func Distribute() func(c *gin.Context) { return func(c *gin.Context) { var channel *model.Channel channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId) + resourceChannelId := common.GetContextKeyInt(c, constant.ContextKeyUpstreamResourceChannelId) modelRequest, shouldSelectChannel, err := getModelRequest(c) if err != nil { abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()})) @@ -45,6 +46,10 @@ func Distribute() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId)) return } + if resourceChannelId > 0 && resourceChannelId != id { + abortWithOpenAiMessage(c, http.StatusForbidden, "the token-specific channel does not own this upstream resource") + return + } channel, err = model.GetChannelById(id, true) if err != nil { abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId)) @@ -77,7 +82,19 @@ func Distribute() func(c *gin.Context) { } } - if shouldSelectChannel { + if resourceChannelId > 0 { + channel, err = model.GetChannelById(resourceChannelId, true) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId)) + return + } + if channel.Status != common.ChannelStatusEnabled { + abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled)) + return + } + } + + if shouldSelectChannel && channel == nil { if modelRequest.Model == "" { abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorModelNameRequired)) return @@ -161,8 +178,19 @@ func Distribute() func(c *gin.Context) { } } } + if !channelSupportsRequestPath(channel, c.Request.URL.Path, modelRequest.Model) { + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, "selected channel does not support this request path") + return + } common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) - SetupContextForSelectedChannel(c, channel, modelRequest.Model) + if setupErr := SetupContextForSelectedChannel(c, channel, modelRequest.Model); setupErr != nil { + statusCode := setupErr.StatusCode + if statusCode < http.StatusBadRequest || setupErr.GetErrorCode() == types.ErrorCodeChannelNoAvailableKey { + statusCode = http.StatusServiceUnavailable + } + abortWithOpenAiMessage(c, statusCode, setupErr.Error(), setupErr.GetErrorCode()) + return + } c.Next() if channel != nil && c.Writer != nil && c.Writer.Status() < http.StatusBadRequest { service.RecordChannelAffinity(c, channel.Id) @@ -171,12 +199,15 @@ func Distribute() func(c *gin.Context) { } // channelSupportsRequestPath reports whether a channel can serve the request path. -// Only Advanced Custom (type 58) channels are path-checked; all other channel types -// always pass. A type-58 channel is usable only when one of its routes matches. +// File/Batch resources require a native OpenAI Batch opt-in; Advanced Custom +// channels are usable only when one of their routes matches. func channelSupportsRequestPath(channel *model.Channel, requestPath string, requestModel string) bool { if channel == nil { return false } + if model.IsOpenAIUpstreamResourcePath(requestPath) { + return channel.SupportsNativeOpenAIBatch() + } if channel.Type != constant.ChannelTypeAdvancedCustom { return true } @@ -251,6 +282,9 @@ func getJSONStringValue(result gjson.Result, field string) (string, error) { } func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { + if modelName := common.GetContextKeyString(c, constant.ContextKeyUpstreamResourceModel); modelName != "" { + return &ModelRequest{Model: modelName}, true, nil + } var modelRequest ModelRequest shouldSelectChannel := true var err error @@ -466,7 +500,19 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode common.SetContextKey(c, constant.ContextKeyChannelModelMapping, channel.GetModelMapping()) common.SetContextKey(c, constant.ContextKeyChannelStatusCodeMapping, channel.GetStatusCodeMapping()) - key, index, newAPIError := channel.GetNextEnabledKey() + var key string + var index int + var newAPIError *types.NewAPIError + resourceKeyFingerprint := common.GetContextKeyString(c, constant.ContextKeyUpstreamResourceKeyFingerprint) + _, hasResourceKeyIndex := common.GetContextKey(c, constant.ContextKeyUpstreamResourceKeyIndex) + switch { + case resourceKeyFingerprint != "": + key, index, newAPIError = channel.GetEnabledKeyByFingerprint(resourceKeyFingerprint) + case hasResourceKeyIndex && channel.ChannelInfo.IsMultiKey: + key, index, newAPIError = channel.GetEnabledKeyByIndex(common.GetContextKeyInt(c, constant.ContextKeyUpstreamResourceKeyIndex)) + default: + key, index, newAPIError = channel.GetNextEnabledKey() + } if newAPIError != nil { return newAPIError } diff --git a/middleware/openai_upstream_resource.go b/middleware/openai_upstream_resource.go new file mode 100644 index 000000000000..fa36a94d1870 --- /dev/null +++ b/middleware/openai_upstream_resource.go @@ -0,0 +1,256 @@ +package middleware + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" +) + +type openAIBatchInputLine struct { + CustomId string `json:"custom_id"` + Method string `json:"method"` + URL string `json:"url"` + Body struct { + Model string `json:"model"` + } `json:"body"` +} + +var supportedOpenAIBatchEndpoints = map[string]struct{}{ + "/v1/responses": {}, + "/v1/chat/completions": {}, + "/v1/embeddings": {}, + "/v1/completions": {}, + "/v1/images/generations": {}, + "/v1/images/edits": {}, +} + +type openAIBatchCreateRequest struct { + InputFileId string `json:"input_file_id"` +} + +const maxOpenAIBatchRequests = 50_000 + +func extractOpenAIBatchUploadModel(c *gin.Context) (string, error) { + mediaType, params, err := mime.ParseMediaType(c.Request.Header.Get("Content-Type")) + if err != nil || mediaType != "multipart/form-data" || params["boundary"] == "" { + return "", errors.New("multipart/form-data request is required") + } + + storage, err := common.GetBodyStorage(c) + if err != nil { + return "", err + } + reader, err := storage.NewReader() + if err != nil { + return "", err + } + defer reader.Close() + + multipartReader := multipart.NewReader(reader, params["boundary"]) + purpose := "" + purposeFound := false + modelName := "" + fileFound := false + customIds := make(map[string]struct{}) + for { + part, nextErr := multipartReader.NextPart() + if errors.Is(nextErr, io.EOF) { + break + } + if nextErr != nil { + return "", fmt.Errorf("invalid multipart request: %w", nextErr) + } + + switch part.FormName() { + case "purpose": + if purposeFound { + part.Close() + return "", errors.New("duplicate purpose field") + } + purposeFound = true + value, readErr := io.ReadAll(io.LimitReader(part, 64)) + if readErr != nil { + part.Close() + return "", readErr + } + purpose = strings.TrimSpace(string(value)) + case "file": + if fileFound { + part.Close() + return "", errors.New("duplicate file field") + } + fileFound = true + scanner := bufio.NewScanner(part) + maxRequestBodyMB := constant.MaxRequestBodyMB + if maxRequestBodyMB <= 0 { + maxRequestBodyMB = 128 + } + scanner.Buffer(make([]byte, 64*1024), maxRequestBodyMB<<20) + lineNumber := 0 + for scanner.Scan() { + rawLine := bytes.TrimSpace(scanner.Bytes()) + if len(rawLine) == 0 { + continue + } + lineNumber++ + if lineNumber > maxOpenAIBatchRequests { + part.Close() + return "", fmt.Errorf("batch input file must not exceed %d requests", maxOpenAIBatchRequests) + } + var line openAIBatchInputLine + if decodeErr := common.Unmarshal(rawLine, &line); decodeErr != nil { + part.Close() + return "", fmt.Errorf("invalid batch input file at line %d: %w", lineNumber, decodeErr) + } + line.CustomId = strings.TrimSpace(line.CustomId) + if line.CustomId == "" { + part.Close() + return "", fmt.Errorf("custom_id is required at line %d", lineNumber) + } + if _, duplicate := customIds[line.CustomId]; duplicate { + part.Close() + return "", fmt.Errorf("custom_id must be unique at line %d", lineNumber) + } + customIds[line.CustomId] = struct{}{} + if line.Method != http.MethodPost { + part.Close() + return "", fmt.Errorf("batch method must be POST at line %d", lineNumber) + } + if _, supported := supportedOpenAIBatchEndpoints[line.URL]; !supported { + part.Close() + return "", fmt.Errorf("unsupported batch endpoint %q at line %d", line.URL, lineNumber) + } + lineModel := strings.TrimSpace(line.Body.Model) + if lineModel == "" { + part.Close() + return "", fmt.Errorf("model is required at line %d", lineNumber) + } + if modelName == "" { + modelName = lineModel + } else if modelName != lineModel { + part.Close() + return "", errors.New("all batch requests must use the same model") + } + } + if scanErr := scanner.Err(); scanErr != nil { + part.Close() + return "", fmt.Errorf("invalid batch input file: %w", scanErr) + } + } + part.Close() + } + + if purpose != "batch" { + return "", errors.New("purpose must be batch") + } + if !fileFound { + return "", errors.New("batch input file is required") + } + if modelName == "" { + return "", errors.New("model is required in the first batch input request") + } + return modelName, nil +} + +// PrepareOpenAIUpstreamResource resolves the model and channel before the +// regular distributor runs. Uploads select a channel by model; all later +// resource operations are pinned to the channel stored for the owning user. +func PrepareOpenAIUpstreamResource() gin.HandlerFunc { + return func(c *gin.Context) { + if !operation_setting.IsOpenAIBatchEnabled() { + abortWithOpenAiMessage(c, http.StatusNotFound, "OpenAI Batch API is not enabled") + return + } + userId := common.GetContextKeyInt(c, constant.ContextKeyUserId) + path := c.Request.URL.Path + method := c.Request.Method + + var modelName string + var channelId int + var resource *model.OpenAIUpstreamResource + switch { + case method == http.MethodPost && path == "/v1/files": + var err error + modelName, err = extractOpenAIBatchUploadModel(c) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, err.Error()) + return + } + case method == http.MethodPost && path == "/v1/batches": + var request openAIBatchCreateRequest + if err := common.UnmarshalBodyReusable(c, &request); err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "invalid batch request: "+err.Error()) + return + } + request.InputFileId = strings.TrimSpace(request.InputFileId) + if request.InputFileId == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "input_file_id is required") + return + } + var found bool + var err error + resource, found, err = model.GetOpenAIUpstreamResource(userId, model.OpenAIUpstreamResourceTypeFile, request.InputFileId) + if err != nil { + abortWithOpenAiMessage(c, http.StatusInternalServerError, "failed to resolve input file") + return + } + if !found { + abortWithOpenAiMessage(c, http.StatusNotFound, "input file not found") + return + } + modelName = resource.Model + channelId = resource.ChannelId + case strings.HasPrefix(path, "/v1/batches/"): + var found bool + var err error + resource, found, err = model.GetOpenAIUpstreamResource(userId, model.OpenAIUpstreamResourceTypeBatch, c.Param("id")) + if err != nil { + abortWithOpenAiMessage(c, http.StatusInternalServerError, "failed to resolve batch") + return + } + if !found { + abortWithOpenAiMessage(c, http.StatusNotFound, "batch not found") + return + } + modelName = resource.Model + channelId = resource.ChannelId + case strings.HasPrefix(path, "/v1/files/"): + var found bool + var err error + resource, found, err = model.GetOpenAIUpstreamResource(userId, model.OpenAIUpstreamResourceTypeFile, c.Param("id")) + if err != nil { + abortWithOpenAiMessage(c, http.StatusInternalServerError, "failed to resolve file") + return + } + if !found { + abortWithOpenAiMessage(c, http.StatusNotFound, "file not found") + return + } + modelName = resource.Model + channelId = resource.ChannelId + default: + abortWithOpenAiMessage(c, http.StatusNotFound, "resource endpoint not found") + return + } + + common.SetContextKey(c, constant.ContextKeyUpstreamResourceModel, modelName) + if channelId > 0 { + common.SetContextKey(c, constant.ContextKeyUpstreamResourceChannelId, channelId) + common.SetContextKey(c, constant.ContextKeyUpstreamResourceKeyIndex, resource.ChannelKeyIndex) + common.SetContextKey(c, constant.ContextKeyUpstreamResourceKeyFingerprint, resource.ChannelKeyFingerprint) + } + c.Next() + } +} diff --git a/middleware/openai_upstream_resource_test.go b/middleware/openai_upstream_resource_test.go new file mode 100644 index 000000000000..06171f795ff5 --- /dev/null +++ b/middleware/openai_upstream_resource_test.go @@ -0,0 +1,414 @@ +package middleware + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/setting/config" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func newBatchUploadRequest(t *testing.T, purpose string, jsonl string, purposeAfterFile bool) (*http.Request, []byte) { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + writePurpose := func() { + require.NoError(t, writer.WriteField("purpose", purpose)) + } + if !purposeAfterFile { + writePurpose() + } + file, err := writer.CreateFormFile("file", "batch.jsonl") + require.NoError(t, err) + _, err = file.Write([]byte(jsonl)) + require.NoError(t, err) + if purposeAfterFile { + writePurpose() + } + require.NoError(t, writer.Close()) + + rawBody := append([]byte(nil), body.Bytes()...) + request := httptest.NewRequest(http.MethodPost, "/v1/files", bytes.NewReader(rawBody)) + request.Header.Set("Content-Type", writer.FormDataContentType()) + return request, rawBody +} + +func TestExtractOpenAIBatchUploadModel(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + purpose string + jsonl string + purposeAfterFile bool + wantModel string + wantError string + }{ + { + name: "extracts model when purpose follows file", + purpose: "batch", + jsonl: "\n {\"custom_id\":\"image-1\",\"method\":\"POST\",\"url\":\"/v1/images/generations\",\"body\":{\"model\":\"gpt-image-2\",\"prompt\":\"test\"}}\n", + purposeAfterFile: true, + wantModel: "gpt-image-2", + }, + { + name: "rejects non batch purpose", + purpose: "assistants", + jsonl: "{\"custom_id\":\"image-1\",\"method\":\"POST\",\"url\":\"/v1/images/generations\",\"body\":{\"model\":\"gpt-image-2\"}}\n", + wantError: "purpose must be batch", + }, + { + name: "rejects malformed JSONL", + purpose: "batch", + jsonl: "{not-json}\n", + wantError: "invalid batch input file", + }, + { + name: "rejects missing model", + purpose: "batch", + jsonl: "{\"custom_id\":\"image-1\",\"method\":\"POST\",\"url\":\"/v1/images/generations\",\"body\":{\"prompt\":\"test\"}}\n", + wantError: "model is required", + }, + { + name: "rejects a different model in a later request", + purpose: "batch", + jsonl: "{\"custom_id\":\"first\",\"method\":\"POST\",\"url\":\"/v1/images/generations\",\"body\":{\"model\":\"gpt-image-2\"}}\n" + + "{\"custom_id\":\"second\",\"method\":\"POST\",\"url\":\"/v1/images/generations\",\"body\":{\"model\":\"gpt-image-1\"}}\n", + wantError: "same model", + }, + { + name: "rejects malformed later request", + purpose: "batch", + jsonl: "{\"custom_id\":\"first\",\"method\":\"POST\",\"url\":\"/v1/images/generations\",\"body\":{\"model\":\"gpt-image-2\"}}\n" + + "{not-json}\n", + wantError: "line 2", + }, + { + name: "rejects unsupported endpoint", + purpose: "batch", + jsonl: "{\"custom_id\":\"first\",\"method\":\"POST\",\"url\":\"/v1/unknown\",\"body\":{\"model\":\"gpt-image-2\"}}\n", + wantError: "unsupported batch endpoint", + }, + { + name: "rejects duplicate custom ids", + purpose: "batch", + jsonl: "{\"custom_id\":\"same\",\"method\":\"POST\",\"url\":\"/v1/images/generations\",\"body\":{\"model\":\"gpt-image-2\"}}\n" + + "{\"custom_id\":\"same\",\"method\":\"POST\",\"url\":\"/v1/images/generations\",\"body\":{\"model\":\"gpt-image-2\"}}\n", + wantError: "custom_id must be unique", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + request, rawBody := newBatchUploadRequest(t, test.purpose, test.jsonl, test.purposeAfterFile) + context.Request = request + + got, err := extractOpenAIBatchUploadModel(context) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + return + } + require.NoError(t, err) + assert.Equal(t, test.wantModel, got) + + bodyAfter, err := common.GetBodyStorage(context) + require.NoError(t, err) + bodyReader, err := bodyAfter.NewReader() + require.NoError(t, err) + defer bodyReader.Close() + forwardedBody, err := io.ReadAll(bodyReader) + require.NoError(t, err) + assert.Equal(t, rawBody, forwardedBody) + }) + } +} + +func TestExtractOpenAIBatchUploadModelRejectsDuplicateFileParts(t *testing.T) { + gin.SetMode(gin.TestMode) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("purpose", "batch")) + for index, modelName := range []string{"gpt-image-2", "gpt-image-1"} { + file, err := writer.CreateFormFile("file", fmt.Sprintf("batch-%d.jsonl", index)) + require.NoError(t, err) + _, err = fmt.Fprintf(file, `{"custom_id":"image-%d","method":"POST","url":"/v1/images/generations","body":{"model":"%s"}}`+"\n", index, modelName) + require.NoError(t, err) + } + require.NoError(t, writer.Close()) + + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = httptest.NewRequest(http.MethodPost, "/v1/files", bytes.NewReader(body.Bytes())) + context.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + _, err := extractOpenAIBatchUploadModel(context) + require.ErrorContains(t, err, "duplicate file") +} + +func TestExtractOpenAIBatchUploadModelRejectsDuplicatePurposeParts(t *testing.T) { + gin.SetMode(gin.TestMode) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("purpose", "batch")) + require.NoError(t, writer.WriteField("purpose", "batch")) + file, err := writer.CreateFormFile("file", "batch.jsonl") + require.NoError(t, err) + _, err = io.WriteString(file, `{"custom_id":"image-1","method":"POST","url":"/v1/images/generations","body":{"model":"gpt-image-2"}}`+"\n") + require.NoError(t, err) + require.NoError(t, writer.Close()) + + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = httptest.NewRequest(http.MethodPost, "/v1/files", bytes.NewReader(body.Bytes())) + context.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + _, err = extractOpenAIBatchUploadModel(context) + require.ErrorContains(t, err, "duplicate purpose") +} + +func TestExtractOpenAIBatchUploadModelRejectsMoreThanFiftyThousandRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + var jsonl strings.Builder + for index := 0; index <= 50_000; index++ { + _, err := fmt.Fprintf(&jsonl, `{"custom_id":"image-%d","method":"POST","url":"/v1/images/generations","body":{"model":"gpt-image-2"}}`+"\n", index) + require.NoError(t, err) + } + + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + request, _ := newBatchUploadRequest(t, "batch", jsonl.String(), false) + context.Request = request + + _, err := extractOpenAIBatchUploadModel(context) + require.ErrorContains(t, err, "must not exceed 50000 requests") +} + +func setupOpenAIUpstreamResourceMiddlewareTest(t *testing.T) { + t.Helper() + previousDB := model.DB + previousType := common.MainDatabaseType() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.OpenAIUpstreamResource{})) + model.DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{"openai_batch_setting.enabled": "true"})) + t.Cleanup(func() { + model.DB = previousDB + common.SetMainDatabaseType(previousType) + _ = config.GlobalConfig.LoadFromDB(map[string]string{"openai_batch_setting.enabled": "false"}) + }) +} + +type readCountingBody struct { + reads int +} + +func (body *readCountingBody) Read(_ []byte) (int, error) { + body.reads++ + return 0, io.EOF +} + +func (body *readCountingBody) Close() error { + return nil +} + +func TestPrepareOpenAIUpstreamResourceIsDisabledByDefaultAndDoesNotReadBody(t *testing.T) { + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{"openai_batch_setting.enabled": "false"})) + body := &readCountingBody{} + handled := false + router := gin.New() + router.POST("/v1/files", PrepareOpenAIUpstreamResource(), func(c *gin.Context) { + handled = true + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "/v1/files", body) + request.Header.Set("Content-Type", "multipart/form-data; boundary=test") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusNotFound, response.Code) + assert.False(t, handled) + assert.Zero(t, body.reads) +} + +func TestPrepareOpenAIUpstreamResourceReusesInputFileChannel(t *testing.T) { + setupOpenAIUpstreamResourceMiddlewareTest(t) + baseURL := "https://upstream.example" + channel := &model.Channel{ + Id: 71, + Type: constant.ChannelTypeOpenAI, + Key: "sk-test", + Status: common.ChannelStatusEnabled, + BaseURL: &baseURL, + } + channel.SetOtherSettings(dto.ChannelOtherSettings{NativeOpenAIBatch: true}) + require.NoError(t, model.DB.Create(channel).Error) + require.NoError(t, model.SaveOpenAIUpstreamResources([]model.OpenAIUpstreamResource{{ + UserId: 101, + ChannelId: channel.Id, + ResourceType: model.OpenAIUpstreamResourceTypeFile, + ResourceId: "file_input", + Model: "gpt-image-2", + }})) + + router := gin.New() + router.POST("/v1/batches", func(c *gin.Context) { + common.SetContextKey(c, constant.ContextKeyUserId, 101) + common.SetContextKey(c, constant.ContextKeyUsingGroup, "default") + }, PrepareOpenAIUpstreamResource(), Distribute(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "channel_id": c.GetInt(string(constant.ContextKeyChannelId)), + "model": c.GetString(string(constant.ContextKeyOriginalModel)), + }) + }) + + requestBody := []byte(`{"input_file_id":"file_input","endpoint":"/v1/images/generations","completion_window":"24h"}`) + request := httptest.NewRequest(http.MethodPost, "/v1/batches", bytes.NewReader(requestBody)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + var got struct { + ChannelId int `json:"channel_id"` + Model string `json:"model"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &got)) + assert.Equal(t, channel.Id, got.ChannelId) + assert.Equal(t, "gpt-image-2", got.Model) +} + +func TestPrepareOpenAIUpstreamResourceDoesNotExposeAnotherUsersFile(t *testing.T) { + setupOpenAIUpstreamResourceMiddlewareTest(t) + require.NoError(t, model.SaveOpenAIUpstreamResources([]model.OpenAIUpstreamResource{{ + UserId: 101, + ChannelId: 71, + ResourceType: model.OpenAIUpstreamResourceTypeFile, + ResourceId: "file_private", + Model: "gpt-image-2", + }})) + + handled := false + router := gin.New() + router.POST("/v1/batches", func(c *gin.Context) { + common.SetContextKey(c, constant.ContextKeyUserId, 202) + }, PrepareOpenAIUpstreamResource(), func(c *gin.Context) { + handled = true + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "/v1/batches", bytes.NewBufferString(`{"input_file_id":"file_private"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusNotFound, response.Code) + assert.False(t, handled) +} + +func TestPrepareOpenAIUpstreamResourcePinsTheCreatingMultiKey(t *testing.T) { + setupOpenAIUpstreamResourceMiddlewareTest(t) + baseURL := "https://upstream.example" + channel := &model.Channel{ + Id: 72, + Type: constant.ChannelTypeOpenAI, + Key: "key-a\nkey-b", + Status: common.ChannelStatusEnabled, + BaseURL: &baseURL, + ChannelInfo: model.ChannelInfo{ + IsMultiKey: true, + MultiKeySize: 2, + }, + } + channel.SetOtherSettings(dto.ChannelOtherSettings{NativeOpenAIBatch: true}) + require.NoError(t, model.DB.Create(channel).Error) + require.NoError(t, model.SaveOpenAIUpstreamResources([]model.OpenAIUpstreamResource{{ + UserId: 101, + ChannelId: channel.Id, + ChannelKeyIndex: 1, + ChannelKeyFingerprint: model.ChannelKeyFingerprint("key-b"), + ResourceType: model.OpenAIUpstreamResourceTypeFile, + ResourceId: "file_key_b", + Model: "gpt-image-2", + }})) + + for range 2 { + router := gin.New() + router.POST("/v1/batches", func(c *gin.Context) { + common.SetContextKey(c, constant.ContextKeyUserId, 101) + common.SetContextKey(c, constant.ContextKeyUsingGroup, "default") + }, PrepareOpenAIUpstreamResource(), Distribute(), func(c *gin.Context) { + assert.Equal(t, "key-b", common.GetContextKeyString(c, constant.ContextKeyChannelKey)) + assert.Equal(t, 1, common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)) + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "/v1/batches", bytes.NewBufferString(`{"input_file_id":"file_key_b"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusNoContent, response.Code, response.Body.String()) + } +} + +func TestPrepareOpenAIUpstreamResourceRejectsMissingPinnedKey(t *testing.T) { + setupOpenAIUpstreamResourceMiddlewareTest(t) + baseURL := "https://upstream.example" + channel := &model.Channel{ + Id: 73, + Type: constant.ChannelTypeOpenAI, + Key: "current-key", + Status: common.ChannelStatusEnabled, + BaseURL: &baseURL, + } + channel.SetOtherSettings(dto.ChannelOtherSettings{NativeOpenAIBatch: true}) + require.NoError(t, model.DB.Create(channel).Error) + require.NoError(t, model.SaveOpenAIUpstreamResources([]model.OpenAIUpstreamResource{{ + UserId: 101, + ChannelId: channel.Id, + ChannelKeyFingerprint: model.ChannelKeyFingerprint("removed-key"), + ResourceType: model.OpenAIUpstreamResourceTypeBatch, + ResourceId: "batch_removed_key", + Model: "gpt-image-2", + }})) + + handled := false + router := gin.New() + router.GET("/v1/batches/:id", func(c *gin.Context) { + common.SetContextKey(c, constant.ContextKeyUserId, 101) + common.SetContextKey(c, constant.ContextKeyUsingGroup, "default") + }, PrepareOpenAIUpstreamResource(), Distribute(), func(c *gin.Context) { + handled = true + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/v1/batches/batch_removed_key", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusServiceUnavailable, response.Code) + assert.False(t, handled) +} + +func TestChannelSupportsRequestPathRequiresNativeBatchOptIn(t *testing.T) { + channel := &model.Channel{Type: constant.ChannelTypeOpenAI} + + assert.False(t, channelSupportsRequestPath(channel, "/v1/files", "gpt-image-2")) + assert.True(t, channelSupportsRequestPath(channel, "/v1/chat/completions", "gpt-image-2")) + + channel.SetOtherSettings(dto.ChannelOtherSettings{NativeOpenAIBatch: true}) + assert.True(t, channelSupportsRequestPath(channel, "/v1/files", "gpt-image-2")) + assert.True(t, channelSupportsRequestPath(channel, "/v1/batches/batch_123", "gpt-image-2")) + + channel.Type = constant.ChannelTypeAzure + assert.False(t, channelSupportsRequestPath(channel, "/v1/files", "gpt-image-2")) +} diff --git a/model/ability.go b/model/ability.go index d950a6adbfc4..cc161d817567 100644 --- a/model/ability.go +++ b/model/ability.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "sort" "strings" "sync" @@ -108,20 +109,24 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) { var abilities []Ability - var err error = nil - channelQuery, err := getChannelQuery(group, model, retry) - if err != nil { - return nil, err - } - if common.UsingMainDatabase(common.DatabaseTypeSQLite) || common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { - err = channelQuery.Order("weight DESC").Find(&abilities).Error + var err error + if IsOpenAIUpstreamResourcePath(requestPath) { + err = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true). + Order("weight DESC").Find(&abilities).Error } else { + channelQuery, queryErr := getChannelQuery(group, model, retry) + if queryErr != nil { + return nil, queryErr + } err = channelQuery.Order("weight DESC").Find(&abilities).Error } if err != nil { return nil, err } abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model) + if IsOpenAIUpstreamResourcePath(requestPath) { + abilities = filterAbilitiesByRetryPriority(abilities, retry) + } channel := Channel{} if len(abilities) > 0 { // Randomly choose one @@ -146,11 +151,47 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha return &channel, err } +func filterAbilitiesByRetryPriority(abilities []Ability, retry int) []Ability { + if len(abilities) == 0 { + return abilities + } + prioritySet := make(map[int64]struct{}) + for _, ability := range abilities { + priority := int64(0) + if ability.Priority != nil { + priority = *ability.Priority + } + prioritySet[priority] = struct{}{} + } + priorities := make([]int64, 0, len(prioritySet)) + for priority := range prioritySet { + priorities = append(priorities, priority) + } + sort.Slice(priorities, func(i, j int) bool { return priorities[i] > priorities[j] }) + if retry < 0 { + retry = 0 + } + if retry >= len(priorities) { + retry = len(priorities) - 1 + } + targetPriority := priorities[retry] + filtered := make([]Ability, 0, len(abilities)) + for _, ability := range abilities { + priority := int64(0) + if ability.Priority != nil { + priority = *ability.Priority + } + if priority == targetPriority { + filtered = append(filtered, ability) + } + } + return filtered +} + // filterAbilitiesByRequestPathAndModel restricts candidates by request path and -// model for the DB (non-memory-cache) selection path. Only Advanced Custom -// (type 58) channels are path-checked: kept only when one of their routes matches -// requestPath and model; all other channel types always pass. When requestPath is -// empty, filtering is skipped. +// model for the DB (non-memory-cache) selection path. OpenAI File/Batch paths +// require an explicit native Batch opt-in; Advanced Custom (type 58) paths must +// match a configured route. When requestPath is empty, filtering is skipped. func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath string, model string) []Ability { if requestPath == "" || len(abilities) == 0 { return abilities @@ -168,12 +209,17 @@ func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath strin var channels []*Channel if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil { - // On error, fall back to unfiltered candidates to avoid blocking selection + if IsOpenAIUpstreamResourcePath(requestPath) { + return nil + } + // On error, fall back to unfiltered candidates for existing relay paths. return abilities } advancedConfigs := make(map[int]*dto.AdvancedCustomConfig) + channelsByID := make(map[int]*Channel, len(channels)) for _, channel := range channels { + channelsByID[channel.Id] = channel if channel.Type == constant.ChannelTypeAdvancedCustom { advancedConfigs[channel.Id] = channel.GetOtherSettings().AdvancedCustom } @@ -181,6 +227,13 @@ func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath strin filtered := make([]Ability, 0, len(abilities)) for _, ability := range abilities { + if IsOpenAIUpstreamResourcePath(requestPath) { + channel, ok := channelsByID[ability.ChannelId] + if ok && channel.SupportsNativeOpenAIBatch() { + filtered = append(filtered, ability) + } + continue + } config, isAdvancedCustom := advancedConfigs[ability.ChannelId] if !isAdvancedCustom { filtered = append(filtered, ability) diff --git a/model/channel.go b/model/channel.go index 2cd7c3115ff6..bf342f45717d 100644 --- a/model/channel.go +++ b/model/channel.go @@ -282,6 +282,32 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { } } +func ChannelKeyFingerprint(key string) string { + return common.Sha1([]byte(key)) +} + +func (channel *Channel) GetEnabledKeyByIndex(index int) (string, int, *types.NewAPIError) { + keys := channel.GetKeys() + if index < 0 || index >= len(keys) { + return "", 0, types.NewError(errors.New("upstream resource key index is out of range"), types.ErrorCodeChannelNoAvailableKey) + } + if status, exists := channel.ChannelInfo.MultiKeyStatusList[index]; exists && status != common.ChannelStatusEnabled { + return "", 0, types.NewError(errors.New("upstream resource key is disabled"), types.ErrorCodeChannelNoAvailableKey) + } + return keys[index], index, nil +} + +func (channel *Channel) GetEnabledKeyByFingerprint(fingerprint string) (string, int, *types.NewAPIError) { + fingerprint = strings.TrimSpace(fingerprint) + for index, key := range channel.GetKeys() { + if ChannelKeyFingerprint(key) != fingerprint { + continue + } + return channel.GetEnabledKeyByIndex(index) + } + return "", 0, types.NewError(errors.New("upstream resource key no longer exists"), types.ErrorCodeChannelNoAvailableKey) +} + func (channel *Channel) SaveChannelInfo() error { return DB.Model(channel).Update("channel_info", channel.ChannelInfo).Error } @@ -1017,6 +1043,10 @@ func (channel *Channel) GetOtherSettings() dto.ChannelOtherSettings { return setting } +func (channel *Channel) SupportsNativeOpenAIBatch() bool { + return channel != nil && channel.Type == constant.ChannelTypeOpenAI && channel.GetOtherSettings().NativeOpenAIBatch +} + func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) { settingBytes, err := common.Marshal(setting) if err != nil { diff --git a/model/channel_cache.go b/model/channel_cache.go index 86c594384d50..871b8d546363 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -209,9 +209,9 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat } // filterChannelsByRequestPathAndModel restricts candidates by request path and -// model. Only Advanced Custom (type 58) channels are path-checked: they are kept -// only when one of their configured routes matches requestPath and model. All -// other channel types always pass. When requestPath is empty, filtering is skipped. +// model. OpenAI File/Batch paths require an explicit native Batch opt-in; +// Advanced Custom (type 58) paths must match a configured route. When +// requestPath is empty, filtering is skipped. // Caller must hold channelSyncLock (read lock). The cached slice is never mutated. func filterChannelsByRequestPathAndModel(channels []int, requestPath string, model string) []int { if requestPath == "" || len(channels) == 0 { @@ -221,10 +221,19 @@ func filterChannelsByRequestPathAndModel(channels []int, requestPath string, mod for _, channelId := range channels { channel, ok := channelsIDM[channelId] if !ok { + if IsOpenAIUpstreamResourcePath(requestPath) { + continue + } // keep it so the downstream consistency error is raised as before filtered = append(filtered, channelId) continue } + if IsOpenAIUpstreamResourcePath(requestPath) { + if channel.SupportsNativeOpenAIBatch() { + filtered = append(filtered, channelId) + } + continue + } if channel.Type != constant.ChannelTypeAdvancedCustom { filtered = append(filtered, channelId) continue diff --git a/model/main.go b/model/main.go index 21445593e54e..c3497eda2aee 100644 --- a/model/main.go +++ b/model/main.go @@ -290,6 +290,7 @@ func migrateDB() error { &SystemInstance{}, &SystemTask{}, &SystemTaskLock{}, + &OpenAIUpstreamResource{}, &CasbinRule{}, &AuthzRole{}, ) @@ -353,6 +354,7 @@ func migrateDBFast() error { {&SystemInstance{}, "SystemInstance"}, {&SystemTask{}, "SystemTask"}, {&SystemTaskLock{}, "SystemTaskLock"}, + {&OpenAIUpstreamResource{}, "OpenAIUpstreamResource"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/openai_upstream_resource.go b/model/openai_upstream_resource.go new file mode 100644 index 000000000000..1253ba86a408 --- /dev/null +++ b/model/openai_upstream_resource.go @@ -0,0 +1,121 @@ +package model + +import ( + "errors" + "strings" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + OpenAIUpstreamResourceTypeFile = "file" + OpenAIUpstreamResourceTypeBatch = "batch" +) + +func IsOpenAIUpstreamResourcePath(requestPath string) bool { + return requestPath == "/v1/files" || strings.HasPrefix(requestPath, "/v1/files/") || + requestPath == "/v1/batches" || strings.HasPrefix(requestPath, "/v1/batches/") +} + +// OpenAIUpstreamResource keeps asynchronous OpenAI resources on the channel +// where they were created. Resource IDs are scoped by user to prevent one +// account from resolving another account's upstream resources. +type OpenAIUpstreamResource struct { + Id int64 `json:"id" gorm:"primaryKey"` + UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_openai_upstream_resource_owner,priority:1"` + ResourceType string `json:"resource_type" gorm:"type:varchar(16);not null;uniqueIndex:idx_openai_upstream_resource_owner,priority:2"` + ResourceId string `json:"resource_id" gorm:"type:varchar(191);not null;uniqueIndex:idx_openai_upstream_resource_owner,priority:3"` + ChannelId int `json:"channel_id" gorm:"not null;index"` + ChannelKeyIndex int `json:"channel_key_index" gorm:"not null"` + ChannelKeyFingerprint string `json:"-" gorm:"type:varchar(40);not null"` + Model string `json:"model" gorm:"type:varchar(191);not null"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +func (OpenAIUpstreamResource) TableName() string { + return "openai_upstream_resources" +} + +func (resource *OpenAIUpstreamResource) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + if resource.CreatedAt == 0 { + resource.CreatedAt = now + } + resource.UpdatedAt = now + return nil +} + +func SaveOpenAIUpstreamResources(resources []OpenAIUpstreamResource) error { + if len(resources) == 0 { + return nil + } + for i := range resources { + resources[i].ResourceType = strings.TrimSpace(resources[i].ResourceType) + resources[i].ResourceId = strings.TrimSpace(resources[i].ResourceId) + resources[i].Model = strings.TrimSpace(resources[i].Model) + if resources[i].UserId <= 0 || resources[i].ChannelId <= 0 || resources[i].ResourceType == "" || resources[i].ResourceId == "" || resources[i].Model == "" { + return errors.New("invalid OpenAI upstream resource") + } + } + + return DB.Transaction(func(tx *gorm.DB) error { + for i := range resources { + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "user_id"}, + {Name: "resource_type"}, + {Name: "resource_id"}, + }, + DoNothing: true, + }).Create(&resources[i]).Error; err != nil { + return err + } + + var stored OpenAIUpstreamResource + if err := tx.Where( + "user_id = ? AND resource_type = ? AND resource_id = ?", + resources[i].UserId, + resources[i].ResourceType, + resources[i].ResourceId, + ).First(&stored).Error; err != nil { + return err + } + if stored.ChannelId != resources[i].ChannelId || + stored.ChannelKeyIndex != resources[i].ChannelKeyIndex || + stored.ChannelKeyFingerprint != resources[i].ChannelKeyFingerprint || + stored.Model != resources[i].Model { + return errors.New("OpenAI upstream resource already belongs to another channel or key") + } + } + return nil + }) +} + +func GetOpenAIUpstreamResource(userId int, resourceType string, resourceId string) (*OpenAIUpstreamResource, bool, error) { + var resource OpenAIUpstreamResource + err := DB.Where( + "user_id = ? AND resource_type = ? AND resource_id = ?", + userId, + strings.TrimSpace(resourceType), + strings.TrimSpace(resourceId), + ).First(&resource).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &resource, true, nil +} + +func DeleteOpenAIUpstreamResource(userId int, resourceType string, resourceId string) error { + return DB.Where( + "user_id = ? AND resource_type = ? AND resource_id = ?", + userId, + strings.TrimSpace(resourceType), + strings.TrimSpace(resourceId), + ).Delete(&OpenAIUpstreamResource{}).Error +} diff --git a/model/openai_upstream_resource_test.go b/model/openai_upstream_resource_test.go new file mode 100644 index 000000000000..bfe93fb9006c --- /dev/null +++ b/model/openai_upstream_resource_test.go @@ -0,0 +1,202 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenAIUpstreamResourceIsScopedToItsOwner(t *testing.T) { + require.NoError(t, DB.AutoMigrate(&OpenAIUpstreamResource{})) + require.NoError(t, DB.Exec("DELETE FROM openai_upstream_resources").Error) + t.Cleanup(func() { + _ = DB.Exec("DELETE FROM openai_upstream_resources").Error + }) + + require.NoError(t, SaveOpenAIUpstreamResources([]OpenAIUpstreamResource{ + { + UserId: 101, + ChannelId: 7, + ResourceType: OpenAIUpstreamResourceTypeFile, + ResourceId: "file_shared", + Model: "gpt-image-2", + }, + { + UserId: 202, + ChannelId: 9, + ResourceType: OpenAIUpstreamResourceTypeFile, + ResourceId: "file_shared", + Model: "gpt-image-1", + }, + })) + + first, found, err := GetOpenAIUpstreamResource(101, OpenAIUpstreamResourceTypeFile, "file_shared") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, 7, first.ChannelId) + assert.Equal(t, "gpt-image-2", first.Model) + + second, found, err := GetOpenAIUpstreamResource(202, OpenAIUpstreamResourceTypeFile, "file_shared") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, 9, second.ChannelId) + assert.Equal(t, "gpt-image-1", second.Model) + + _, found, err = GetOpenAIUpstreamResource(303, OpenAIUpstreamResourceTypeFile, "file_shared") + require.NoError(t, err) + assert.False(t, found) +} + +func TestSaveOpenAIUpstreamResourcesRejectsConflictingBinding(t *testing.T) { + require.NoError(t, DB.AutoMigrate(&OpenAIUpstreamResource{})) + require.NoError(t, DB.Exec("DELETE FROM openai_upstream_resources").Error) + t.Cleanup(func() { + _ = DB.Exec("DELETE FROM openai_upstream_resources").Error + }) + + resource := OpenAIUpstreamResource{ + UserId: 101, + ChannelId: 7, + ChannelKeyIndex: 1, + ChannelKeyFingerprint: ChannelKeyFingerprint("key-b"), + ResourceType: OpenAIUpstreamResourceTypeBatch, + ResourceId: "batch_123", + Model: "gpt-image-2", + } + require.NoError(t, SaveOpenAIUpstreamResources([]OpenAIUpstreamResource{resource})) + require.NoError(t, SaveOpenAIUpstreamResources([]OpenAIUpstreamResource{resource}), "saving the same binding must be idempotent") + + resource.ChannelId = 8 + require.ErrorContains(t, SaveOpenAIUpstreamResources([]OpenAIUpstreamResource{resource}), "already belongs to another channel or key") + + stored, found, err := GetOpenAIUpstreamResource(101, OpenAIUpstreamResourceTypeBatch, "batch_123") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, 7, stored.ChannelId) + assert.Equal(t, 1, stored.ChannelKeyIndex) + assert.Equal(t, ChannelKeyFingerprint("key-b"), stored.ChannelKeyFingerprint) +} + +func TestChannelGetEnabledKeyByFingerprint(t *testing.T) { + channel := &Channel{ + Key: "key-a\nkey-b", + ChannelInfo: ChannelInfo{ + IsMultiKey: true, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusManuallyDisabled, + }, + }, + } + + key, index, err := channel.GetEnabledKeyByFingerprint(ChannelKeyFingerprint("key-b")) + require.Nil(t, err) + assert.Equal(t, "key-b", key) + assert.Equal(t, 1, index) + + _, _, err = channel.GetEnabledKeyByFingerprint(ChannelKeyFingerprint("key-a")) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "disabled") + + _, _, err = channel.GetEnabledKeyByFingerprint(ChannelKeyFingerprint("missing")) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "no longer exists") +} + +func TestDeleteOpenAIUpstreamResourceOnlyDeletesOwnedResource(t *testing.T) { + require.NoError(t, DB.AutoMigrate(&OpenAIUpstreamResource{})) + require.NoError(t, DB.Exec("DELETE FROM openai_upstream_resources").Error) + t.Cleanup(func() { + _ = DB.Exec("DELETE FROM openai_upstream_resources").Error + }) + require.NoError(t, SaveOpenAIUpstreamResources([]OpenAIUpstreamResource{ + {UserId: 101, ChannelId: 7, ResourceType: OpenAIUpstreamResourceTypeFile, ResourceId: "file_delete", Model: "gpt-image-2"}, + {UserId: 202, ChannelId: 7, ResourceType: OpenAIUpstreamResourceTypeFile, ResourceId: "file_delete", Model: "gpt-image-2"}, + })) + + require.NoError(t, DeleteOpenAIUpstreamResource(101, OpenAIUpstreamResourceTypeFile, "file_delete")) + _, found, err := GetOpenAIUpstreamResource(101, OpenAIUpstreamResourceTypeFile, "file_delete") + require.NoError(t, err) + assert.False(t, found) + _, found, err = GetOpenAIUpstreamResource(202, OpenAIUpstreamResourceTypeFile, "file_delete") + require.NoError(t, err) + assert.True(t, found) +} + +func TestGetChannelForBatchUploadSkipsHigherPriorityChannelWithoutOptIn(t *testing.T) { + previousMemoryCacheEnabled := common.MemoryCacheEnabled + common.MemoryCacheEnabled = false + t.Cleanup(func() { + common.MemoryCacheEnabled = previousMemoryCacheEnabled + }) + + require.NoError(t, DB.AutoMigrate(&Channel{}, &Ability{})) + channelIds := []int{9_901, 9_902} + require.NoError(t, DB.Where("channel_id IN ?", channelIds).Delete(&Ability{}).Error) + require.NoError(t, DB.Where("id IN ?", channelIds).Delete(&Channel{}).Error) + t.Cleanup(func() { + _ = DB.Where("channel_id IN ?", channelIds).Delete(&Ability{}).Error + _ = DB.Where("id IN ?", channelIds).Delete(&Channel{}).Error + }) + + highPriority := int64(100) + lowPriority := int64(10) + weight := uint(100) + unsupported := Channel{ + Id: channelIds[0], + Type: constant.ChannelTypeOpenAI, + Key: "unsupported-key", + Status: common.ChannelStatusEnabled, + Name: "batch-unsupported", + Group: "batch-capability-test", + Models: "gpt-image-2", + Priority: &highPriority, + Weight: &weight, + } + supported := Channel{ + Id: channelIds[1], + Type: constant.ChannelTypeOpenAI, + Key: "supported-key", + Status: common.ChannelStatusEnabled, + Name: "batch-supported", + Group: "batch-capability-test", + Models: "gpt-image-2", + Priority: &lowPriority, + Weight: &weight, + } + supported.SetOtherSettings(dto.ChannelOtherSettings{NativeOpenAIBatch: true}) + require.NoError(t, DB.Create(&unsupported).Error) + require.NoError(t, DB.Create(&supported).Error) + require.NoError(t, unsupported.AddAbilities(nil)) + require.NoError(t, supported.AddAbilities(nil)) + + selected, err := GetRandomSatisfiedChannel("batch-capability-test", "gpt-image-2", 0, "/v1/files") + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, supported.Id, selected.Id) +} + +func TestFilterChannelsForBatchUploadFailsClosedOnMissingCacheEntry(t *testing.T) { + unsupported := &Channel{Id: 9_911, Type: constant.ChannelTypeOpenAI} + supported := &Channel{Id: 9_912, Type: constant.ChannelTypeOpenAI} + supported.SetOtherSettings(dto.ChannelOtherSettings{NativeOpenAIBatch: true}) + + channelSyncLock.Lock() + previousChannels := channelsIDM + channelsIDM = map[int]*Channel{ + unsupported.Id: unsupported, + supported.Id: supported, + } + got := filterChannelsByRequestPathAndModel( + []int{unsupported.Id, 9_913, supported.Id}, + "/v1/files", + "gpt-image-2", + ) + channelsIDM = previousChannels + channelSyncLock.Unlock() + + assert.Equal(t, []int{supported.Id}, got) +} diff --git a/relaykit/dto/channel_settings.go b/relaykit/dto/channel_settings.go index d3ede20d69c5..2799f1b4498a 100644 --- a/relaykit/dto/channel_settings.go +++ b/relaykit/dto/channel_settings.go @@ -67,6 +67,7 @@ const ( type ChannelOtherSettings struct { AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + NativeOpenAIBatch bool `json:"native_openai_batch,omitempty"` VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true diff --git a/router/openai_upstream_resource_test.go b/router/openai_upstream_resource_test.go new file mode 100644 index 000000000000..5c358951e8a9 --- /dev/null +++ b/router/openai_upstream_resource_test.go @@ -0,0 +1,32 @@ +package router + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestSetRelayRouterRegistersOpenAIFileAndBatchWorkflow(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + SetRelayRouter(engine) + + routes := make(map[string]struct{}) + for _, route := range engine.Routes() { + routes[route.Method+" "+route.Path] = struct{}{} + } + for _, route := range []string{ + http.MethodPost + " /v1/files", + http.MethodDelete + " /v1/files/:id", + http.MethodGet + " /v1/files/:id", + http.MethodGet + " /v1/files/:id/content", + http.MethodPost + " /v1/batches", + http.MethodGet + " /v1/batches/:id", + http.MethodPost + " /v1/batches/:id/cancel", + } { + _, found := routes[route] + assert.True(t, found, route) + } +} diff --git a/router/relay-router.go b/router/relay-router.go index e08ecb14bc17..2caa08de30c1 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -79,6 +79,18 @@ func SetRelayRouter(router *gin.Engine) { controller.Relay(c, types.RelayFormatOpenAIRealtime) }) } + { + openAIResourceRouter := relayV1Router.Group("") + openAIResourceRouter.Use(middleware.PrepareOpenAIUpstreamResource()) + openAIResourceRouter.Use(middleware.Distribute()) + openAIResourceRouter.POST("/files", controller.RelayOpenAIUpstreamResource) + openAIResourceRouter.DELETE("/files/:id", controller.RelayOpenAIUpstreamResource) + openAIResourceRouter.GET("/files/:id", controller.RelayOpenAIUpstreamResource) + openAIResourceRouter.GET("/files/:id/content", controller.RelayOpenAIUpstreamResource) + openAIResourceRouter.POST("/batches", controller.RelayOpenAIUpstreamResource) + openAIResourceRouter.GET("/batches/:id", controller.RelayOpenAIUpstreamResource) + openAIResourceRouter.POST("/batches/:id/cancel", controller.RelayOpenAIUpstreamResource) + } { //http router httpRouter := relayV1Router.Group("") @@ -158,10 +170,6 @@ func SetRelayRouter(router *gin.Engine) { // not implemented httpRouter.POST("/images/variations", controller.RelayNotImplemented) httpRouter.GET("/files", controller.RelayNotImplemented) - httpRouter.POST("/files", controller.RelayNotImplemented) - httpRouter.DELETE("/files/:id", controller.RelayNotImplemented) - httpRouter.GET("/files/:id", controller.RelayNotImplemented) - httpRouter.GET("/files/:id/content", controller.RelayNotImplemented) httpRouter.POST("/fine-tunes", controller.RelayNotImplemented) httpRouter.GET("/fine-tunes", controller.RelayNotImplemented) httpRouter.GET("/fine-tunes/:id", controller.RelayNotImplemented) diff --git a/setting/operation_setting/openai_batch_setting.go b/setting/operation_setting/openai_batch_setting.go new file mode 100644 index 000000000000..b5ac6ef2d853 --- /dev/null +++ b/setting/operation_setting/openai_batch_setting.go @@ -0,0 +1,17 @@ +package operation_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type OpenAIBatchSetting struct { + Enabled bool `json:"enabled"` +} + +var openAIBatchSetting = OpenAIBatchSetting{Enabled: false} + +func init() { + config.GlobalConfig.Register("openai_batch_setting", &openAIBatchSetting) +} + +func IsOpenAIBatchEnabled() bool { + return openAIBatchSetting.Enabled +} diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 3380d9e52c24..4e69db837e37 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -281,6 +281,7 @@ const SENSITIVE_FORM_FIELDS = [ 'vertex_key_type', 'aws_key_type', 'azure_responses_version', + 'native_openai_batch', 'force_format', 'thinking_to_content', 'proxy', @@ -338,6 +339,7 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.proxy?.trim() || values.system_prompt?.trim() || values.force_format || + values.native_openai_batch || values.thinking_to_content || values.pass_through_body_enabled || values.system_prompt_override || @@ -4068,30 +4070,56 @@ export function ChannelMutateDrawer({ >
{currentType === 1 && ( - ( - -
- - {t('Force Format')} - - - {t( - 'Force format response to OpenAI standard (OpenAI channel only)' - )} - -
- - - -
- )} - /> + <> + ( + +
+ + {t('Force Format')} + + + {t( + 'Force format response to OpenAI standard (OpenAI channel only)' + )} + +
+ + + +
+ )} + /> + ( + +
+ + {t('Native OpenAI Batch API')} + + + {t( + 'Allow File and Batch API passthrough for this upstream' + )} + +
+ + + +
+ )} + /> + )} - + {t('Auto')} diff --git a/web/src/features/channels/lib/__tests__/openai-batch-settings.test.ts b/web/src/features/channels/lib/__tests__/openai-batch-settings.test.ts new file mode 100644 index 000000000000..35cd8afb7582 --- /dev/null +++ b/web/src/features/channels/lib/__tests__/openai-batch-settings.test.ts @@ -0,0 +1,67 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import type { Channel } from '../../types' +import { + CHANNEL_FORM_DEFAULT_VALUES, + channelFormSchema, + transformChannelToFormDefaults, + transformFormDataToCreatePayload, + transformFormDataToUpdatePayload, +} from '../channel-form' + +describe('OpenAI native Batch channel setting', () => { + test('round-trips through create and edit payloads', () => { + const parsed = channelFormSchema.parse({ + ...CHANNEL_FORM_DEFAULT_VALUES, + name: 'OpenAI Batch upstream', + key: 'test-key', + models: 'gpt-image-2', + native_openai_batch: true, + }) + + assert.equal(parsed.native_openai_batch, true) + const created = transformFormDataToCreatePayload(parsed) + assert.equal( + JSON.parse(String(created.channel.settings)).native_openai_batch, + true + ) + + const existing = { + ...created.channel, + id: 101, + type: 1, + status: 1, + settings: created.channel.settings, + channel_info: { + is_multi_key: false, + multi_key_size: 0, + multi_key_polling_index: 0, + multi_key_mode: 'random', + }, + } as Channel + const editDefaults = transformChannelToFormDefaults(existing) + assert.equal(editDefaults.native_openai_batch, true) + + const updated = transformFormDataToUpdatePayload(editDefaults, existing.id) + assert.equal(JSON.parse(String(updated.settings)).native_openai_batch, true) + }) +}) diff --git a/web/src/features/channels/lib/channel-form-errors.ts b/web/src/features/channels/lib/channel-form-errors.ts index 92716038462e..e76ad4262c71 100644 --- a/web/src/features/channels/lib/channel-form-errors.ts +++ b/web/src/features/channels/lib/channel-form-errors.ts @@ -36,6 +36,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set>([ 'status_code_mapping', 'advanced_custom', 'force_format', + 'native_openai_batch', 'thinking_to_content', 'pass_through_body_enabled', 'proxy', diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts index 22f07931e4e2..c04b42cea7b4 100644 --- a/web/src/features/channels/lib/channel-form.ts +++ b/web/src/features/channels/lib/channel-form.ts @@ -266,6 +266,7 @@ export const channelFormSchema = z vertex_key_type: z.enum(['json', 'api_key']).optional(), // Vertex AI specific aws_key_type: z.enum(['ak_sk', 'api_key']).optional(), // AWS specific azure_responses_version: z.string().optional(), // Azure specific + native_openai_batch: z.boolean().optional(), // OpenAI native File/Batch API // Field passthrough controls (stored in settings JSON) allow_service_tier: z.boolean().optional(), // OpenAI/Anthropic disable_store: z.boolean().optional(), // OpenAI only @@ -438,6 +439,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = { vertex_key_type: 'json', aws_key_type: 'ak_sk', azure_responses_version: '', + native_openai_batch: false, // Field passthrough controls allow_service_tier: false, disable_store: false, @@ -487,8 +489,7 @@ export function transformChannelToFormDefaults( thinking_to_content: parsed.thinking_to_content || false, proxy: parsed.proxy || '', http_protocol: protocol, - http2_connection_shards: - protocol === HTTP_PROTOCOL_HTTP1 ? 1 : shards, + http2_connection_shards: protocol === HTTP_PROTOCOL_HTTP1 ? 1 : shards, pass_through_body_enabled: parsed.pass_through_body_enabled || false, system_prompt: parsed.system_prompt || '', system_prompt_override: parsed.system_prompt_override || false, @@ -504,6 +505,7 @@ export function transformChannelToFormDefaults( let azureResponsesVersion = '' let isEnterpriseAccount = false let awsKeyType: 'ak_sk' | 'api_key' = 'ak_sk' + let nativeOpenAIBatch = false let allowServiceTier = false let disableStore = false let allowSafetyIdentifier = false @@ -524,6 +526,7 @@ export function transformChannelToFormDefaults( azureResponsesVersion = parsed.azure_responses_version || '' isEnterpriseAccount = parsed.openrouter_enterprise === true awsKeyType = parsed.aws_key_type || 'ak_sk' + nativeOpenAIBatch = parsed.native_openai_batch === true allowServiceTier = parsed.allow_service_tier === true disableStore = parsed.disable_store === true allowSafetyIdentifier = parsed.allow_safety_identifier === true @@ -583,6 +586,7 @@ export function transformChannelToFormDefaults( vertex_key_type: vertexKeyType, azure_responses_version: azureResponsesVersion, aws_key_type: awsKeyType, + native_openai_batch: nativeOpenAIBatch, allow_service_tier: allowServiceTier, disable_store: disableStore, allow_include_obfuscation: allowIncludeObfuscation, @@ -671,6 +675,12 @@ function buildSettingsJSON(formData: ChannelFormValues): string { delete settingsObj.aws_key_type } + if (formData.type === 1) { + settingsObj.native_openai_batch = formData.native_openai_batch === true + } else if ('native_openai_batch' in settingsObj) { + delete settingsObj.native_openai_batch + } + // Field passthrough controls: // - OpenAI (type 1) and Anthropic (type 14): allow_service_tier // - OpenAI only: disable_store, allow_safety_identifier diff --git a/web/src/features/channels/types.ts b/web/src/features/channels/types.ts index f7747fa21210..052806299a39 100644 --- a/web/src/features/channels/types.ts +++ b/web/src/features/channels/types.ts @@ -92,6 +92,7 @@ export interface ChannelSettings { export interface ChannelOtherSettings { azure_responses_version?: string + native_openai_batch?: boolean vertex_key_type?: 'json' | 'api_key' openrouter_enterprise?: boolean aws_key_type?: 'ak_sk' | 'api_key' diff --git a/web/src/features/system-settings/general/__tests__/system-behavior-settings.test.ts b/web/src/features/system-settings/general/__tests__/system-behavior-settings.test.ts new file mode 100644 index 000000000000..aff1b04e562b --- /dev/null +++ b/web/src/features/system-settings/general/__tests__/system-behavior-settings.test.ts @@ -0,0 +1,50 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { + getSystemBehaviorOptionUpdates, + toSystemBehaviorFormValues, +} from '../system-behavior-settings' + +describe('system behavior settings', () => { + test('round-trips the nested OpenAI Batch toggle to a flat option update', () => { + const options = { + DefaultCollapseSidebar: false, + DemoSiteEnabled: false, + SelfUseModeEnabled: false, + 'openai_batch_setting.enabled': false, + } + + const formValues = toSystemBehaviorFormValues(options) + assert.deepEqual(formValues.openai_batch_setting, { enabled: false }) + + const updates = getSystemBehaviorOptionUpdates( + { + ...formValues, + openai_batch_setting: { enabled: true }, + }, + options + ) + assert.deepEqual(updates, [ + { key: 'openai_batch_setting.enabled', value: true }, + ]) + }) +}) diff --git a/web/src/features/system-settings/general/system-behavior-section.tsx b/web/src/features/system-settings/general/system-behavior-section.tsx index 5a44c89b7b72..c49ba1435281 100644 --- a/web/src/features/system-settings/general/system-behavior-section.tsx +++ b/web/src/features/system-settings/general/system-behavior-section.tsx @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { zodResolver } from '@hookform/resolvers/zod' +import { useMemo } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import * as z from 'zod' @@ -39,17 +40,24 @@ import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useResetForm } from '../hooks/use-reset-form' import { useUpdateOption } from '../hooks/use-update-option' +import { + getSystemBehaviorOptionUpdates, + toSystemBehaviorFormValues, + type FlatSystemBehaviorOptions, + type SystemBehaviorFormValues, +} from './system-behavior-settings' const behaviorSchema = z.object({ DefaultCollapseSidebar: z.boolean(), DemoSiteEnabled: z.boolean(), SelfUseModeEnabled: z.boolean(), + openai_batch_setting: z.object({ + enabled: z.boolean(), + }), }) -type BehaviorFormValues = z.infer - type SystemBehaviorSectionProps = { - defaultValues: BehaviorFormValues + defaultValues: FlatSystemBehaviorOptions } export function SystemBehaviorSection({ @@ -57,20 +65,22 @@ export function SystemBehaviorSection({ }: SystemBehaviorSectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() + const formDefaults = useMemo( + () => toSystemBehaviorFormValues(defaultValues), + [defaultValues] + ) - const form = useForm({ + const form = useForm({ resolver: zodResolver(behaviorSchema), - defaultValues, + defaultValues: formDefaults, }) - useResetForm(form, defaultValues) + useResetForm(form, formDefaults) - const onSubmit = async (data: BehaviorFormValues) => { - const updates = Object.entries(data).filter( - ([key, value]) => value !== defaultValues[key as keyof BehaviorFormValues] - ) + const onSubmit = async (data: SystemBehaviorFormValues) => { + const updates = getSystemBehaviorOptionUpdates(data, defaultValues) - for (const [key, value] of updates) { + for (const { key, value } of updates) { await updateOption.mutateAsync({ key, value }) } } @@ -145,6 +155,29 @@ export function SystemBehaviorSection({ )} /> + + ( + + + {t('OpenAI Batch API')} + + {t( + 'Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply.' + )} + + + + + + + )} + /> diff --git a/web/src/features/system-settings/general/system-behavior-settings.ts b/web/src/features/system-settings/general/system-behavior-settings.ts new file mode 100644 index 000000000000..6ef55423cff6 --- /dev/null +++ b/web/src/features/system-settings/general/system-behavior-settings.ts @@ -0,0 +1,67 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +export type FlatSystemBehaviorOptions = { + DefaultCollapseSidebar: boolean + DemoSiteEnabled: boolean + SelfUseModeEnabled: boolean + 'openai_batch_setting.enabled': boolean +} + +export type SystemBehaviorFormValues = { + DefaultCollapseSidebar: boolean + DemoSiteEnabled: boolean + SelfUseModeEnabled: boolean + openai_batch_setting: { + enabled: boolean + } +} + +type SystemBehaviorOptionUpdate = { + key: keyof FlatSystemBehaviorOptions + value: boolean +} + +export function toSystemBehaviorFormValues( + options: FlatSystemBehaviorOptions +): SystemBehaviorFormValues { + return { + DefaultCollapseSidebar: options.DefaultCollapseSidebar, + DemoSiteEnabled: options.DemoSiteEnabled, + SelfUseModeEnabled: options.SelfUseModeEnabled, + openai_batch_setting: { + enabled: options['openai_batch_setting.enabled'], + }, + } +} + +export function getSystemBehaviorOptionUpdates( + values: SystemBehaviorFormValues, + baseline: FlatSystemBehaviorOptions +): SystemBehaviorOptionUpdate[] { + const normalized: FlatSystemBehaviorOptions = { + DefaultCollapseSidebar: values.DefaultCollapseSidebar, + DemoSiteEnabled: values.DemoSiteEnabled, + SelfUseModeEnabled: values.SelfUseModeEnabled, + 'openai_batch_setting.enabled': values.openai_batch_setting.enabled, + } + + return (Object.keys(normalized) as Array) + .filter((key) => normalized[key] !== baseline[key]) + .map((key) => ({ key, value: normalized[key] })) +} diff --git a/web/src/features/system-settings/operations/index.tsx b/web/src/features/system-settings/operations/index.tsx index 5ea7070ad649..9ddb64f756fb 100644 --- a/web/src/features/system-settings/operations/index.tsx +++ b/web/src/features/system-settings/operations/index.tsx @@ -30,6 +30,7 @@ const defaultOperationsSettings: OperationsSettings = { DefaultCollapseSidebar: false, DemoSiteEnabled: false, SelfUseModeEnabled: false, + 'openai_batch_setting.enabled': false, QuotaRemindThreshold: '', SMTPServer: '', SMTPPort: '', diff --git a/web/src/features/system-settings/operations/section-registry.tsx b/web/src/features/system-settings/operations/section-registry.tsx index f9473a4056fb..a6f60009f6ac 100644 --- a/web/src/features/system-settings/operations/section-registry.tsx +++ b/web/src/features/system-settings/operations/section-registry.tsx @@ -36,6 +36,8 @@ const OPERATIONS_SECTIONS = [ DefaultCollapseSidebar: settings.DefaultCollapseSidebar, DemoSiteEnabled: settings.DemoSiteEnabled, SelfUseModeEnabled: settings.SelfUseModeEnabled, + 'openai_batch_setting.enabled': + settings['openai_batch_setting.enabled'], }} /> ), diff --git a/web/src/features/system-settings/types.ts b/web/src/features/system-settings/types.ts index 6bb6f2dbc436..4df8d28507bf 100644 --- a/web/src/features/system-settings/types.ts +++ b/web/src/features/system-settings/types.ts @@ -336,6 +336,7 @@ export type OperationsSettings = { DefaultCollapseSidebar: boolean DemoSiteEnabled: boolean SelfUseModeEnabled: boolean + 'openai_batch_setting.enabled': boolean QuotaRemindThreshold: string SMTPServer: string SMTPPort: string diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b48096a7fcfa..b1206cffa572 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -5248,6 +5248,10 @@ "Zero retention": "Zero retention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Native OpenAI Batch API": "Native OpenAI Batch API", + "Allow File and Batch API passthrough for this upstream": "Allow File and Batch API passthrough for this upstream", + "OpenAI Batch API": "OpenAI Batch API", + "Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply.": "Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply." } } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 638cf9abadd1..fc5f190c94f7 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -5248,6 +5248,10 @@ "Zero retention": "Aucune rétention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Native OpenAI Batch API": "API Batch OpenAI native", + "Allow File and Batch API passthrough for this upstream": "Autoriser la transmission des API File et Batch vers ce fournisseur en amont", + "OpenAI Batch API": "API Batch OpenAI", + "Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply.": "Active la transmission native des API File et Batch. New API ne comptabilise pas encore le quota Batch ; les frais du fournisseur en amont s'appliquent toujours." } } diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 3b9d4ce693cc..4d85118574e9 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -5248,6 +5248,10 @@ "Zero retention": "データ保持なし", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V 4", - "Zoom": "ズーム" + "Zoom": "ズーム", + "Native OpenAI Batch API": "ネイティブ OpenAI Batch API", + "Allow File and Batch API passthrough for this upstream": "このアップストリームへの File API と Batch API のパススルーを許可します", + "OpenAI Batch API": "OpenAI Batch API", + "Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply.": "ネイティブの File API と Batch API のパススルーを有効にします。New API は現在 Batch クォータを精算しませんが、アップストリーム料金は引き続き発生します。" } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 853dc874d463..99b8a33b4c04 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -5248,6 +5248,10 @@ "Zero retention": "Без хранения данных", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Native OpenAI Batch API": "Нативный OpenAI Batch API", + "Allow File and Batch API passthrough for this upstream": "Разрешить сквозную передачу File и Batch API для этого вышестоящего сервиса", + "OpenAI Batch API": "Пакетный API OpenAI", + "Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply.": "Включает нативную сквозную передачу File и Batch API. New API пока не списывает квоту Batch, но плата вышестоящего сервиса по-прежнему применяется." } } diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index cdc1a12626e1..6330b01c96dd 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -5248,6 +5248,10 @@ "Zero retention": "Không lưu dữ liệu", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Native OpenAI Batch API": "OpenAI Batch API nguyên bản", + "Allow File and Batch API passthrough for this upstream": "Cho phép chuyển tiếp File API và Batch API tới upstream này", + "OpenAI Batch API": "OpenAI Batch API", + "Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply.": "Bật chuyển tiếp File API và Batch API nguyên bản. New API hiện chưa quyết toán hạn mức Batch; phí upstream vẫn được áp dụng." } } diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index f8b5fafd8d2c..1f73711d349a 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -5248,6 +5248,10 @@ "Zero retention": "零數據保留", "Zhipu": "智譜", "Zhipu V4": "智譜 V4", - "Zoom": "縮放" + "Zoom": "縮放", + "Native OpenAI Batch API": "原生 OpenAI Batch API", + "Allow File and Batch API passthrough for this upstream": "允許將 File 和 Batch API 透傳到此上游", + "OpenAI Batch API": "OpenAI Batch API", + "Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply.": "啟用原生 File 和 Batch 透傳。New API 目前不結算 Batch 額度;上游費用仍會產生。" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 842991e714f0..3508e7a11c74 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -5248,6 +5248,10 @@ "Zero retention": "零数据保留", "Zhipu": "智谱", "Zhipu V4": "智谱 V4", - "Zoom": "缩放" + "Zoom": "缩放", + "Native OpenAI Batch API": "原生 OpenAI Batch API", + "Allow File and Batch API passthrough for this upstream": "允许将 File 和 Batch API 透传到此上游", + "OpenAI Batch API": "OpenAI Batch API", + "Enable native File and Batch passthrough. New API does not currently settle Batch quota; upstream charges still apply.": "启用原生 File 和 Batch 透传。New API 当前不结算 Batch 额度;上游费用仍会产生。" } }