diff --git a/bin/time_test.sh b/bin/time_test.sh index 2cde4a65bbd..95913fad004 100644 --- a/bin/time_test.sh +++ b/bin/time_test.sh @@ -15,10 +15,10 @@ times=() for ((i=1; i<=count; i++)); do result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \ - https://"$domain"/v1/chat/completions \ + https://"$domain"/v1/responses \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $key" \ - -d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}') + -d '{"input": [{"role": "user", "content": "echo hi"}], "model": "'"$model"'", "stream": false, "max_output_tokens": 1}') http_code=$(echo "$result" | awk '{print $1}') time=$(echo "$result" | awk '{print $2}') echo "HTTP status code: $http_code, Time taken: $time" diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index c04c5f6d8e0..ba7a19cd48f 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -8,7 +8,7 @@ import "github.com/QuantumNous/new-api/constant" // 目前均为 POST,后续可扩展 // // json 标签用于直接序列化到 API 输出 -// 例如:{"path":"/v1/chat/completions","method":"POST"} +// 例如:{"path":"/v1/responses","method":"POST"} type EndpointInfo struct { Path string `json:"path"` @@ -17,7 +17,7 @@ type EndpointInfo struct { // defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ - constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"}, + constant.EndpointTypeOpenAI: {Path: "/v1/responses", Method: "POST"}, constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"}, constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, diff --git a/controller/channel-test.go b/controller/channel-test.go index 1c77fb030eb..c424bef8216 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -75,7 +75,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } - requestPath := "/v1/chat/completions" + requestPath := "/v1/responses" // 如果指定了端点类型,使用指定的端点类型 if endpointType != "" { @@ -137,7 +137,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) // 根据指定的端点类型设置 relayFormat switch constant.EndpointType(endpointType) { case constant.EndpointTypeOpenAI: - relayFormat = types.RelayFormatOpenAI + relayFormat = types.RelayFormatOpenAIResponses case constant.EndpointTypeOpenAIResponse: relayFormat = types.RelayFormatOpenAIResponses case constant.EndpointTypeAnthropic: @@ -155,7 +155,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } else { // 根据请求路径自动检测 - relayFormat = types.RelayFormatOpenAI + relayFormat = types.RelayFormatOpenAIResponses if c.Request.URL.Path == "/v1/embeddings" { relayFormat = types.RelayFormatEmbedding } @@ -176,7 +176,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } - request := buildTestRequest(testModel, endpointType) + request := buildTestRequest(testModel, endpointType, requestPath) info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) @@ -389,7 +389,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } -func buildTestRequest(model string, endpointType string) dto.Request { +func buildTestRequest(model string, endpointType string, requestPath string) dto.Request { // 根据端点类型构建不同的测试请求 if endpointType != "" { switch constant.EndpointType(endpointType) { @@ -415,14 +415,16 @@ func buildTestRequest(model string, endpointType string) dto.Request { Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."}, TopN: 2, } - case constant.EndpointTypeOpenAIResponse: + case constant.EndpointTypeOpenAIResponse, constant.EndpointTypeOpenAI: // 返回 OpenAIResponsesRequest return &dto.OpenAIResponsesRequest{ - Model: model, - Input: json.RawMessage("\"hi\""), + Model: model, + Input: json.RawMessage(`[{"role":"user","content":"hi"}]`), + MaxOutputTokens: 10, + Stream: false, } - case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: - // 返回 GeneralOpenAIRequest + case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini: + // 返回 GeneralOpenAIRequest(兼容现有适配逻辑) maxTokens := uint(10) if constant.EndpointType(endpointType) == constant.EndpointTypeGemini { maxTokens = 3000 @@ -441,43 +443,28 @@ func buildTestRequest(model string, endpointType string) dto.Request { } } - // 自动检测逻辑(保持原有行为) - // 先判断是否为 Embedding 模型 - if strings.Contains(strings.ToLower(model), "embedding") || - strings.HasPrefix(model, "m3e") || - strings.Contains(model, "bge-") { - // 返回 EmbeddingRequest + // 自动检测逻辑(按最终请求路径构造) + switch requestPath { + case "/v1/embeddings": return &dto.EmbeddingRequest{ Model: model, Input: []any{"hello world"}, } - } - - // Chat/Completion 请求 - 返回 GeneralOpenAIRequest - testRequest := &dto.GeneralOpenAIRequest{ - Model: model, - Stream: false, - Messages: []dto.Message{ - { - Role: "user", - Content: "hi", - }, - }, - } - - if strings.HasPrefix(model, "o") { - testRequest.MaxCompletionTokens = 10 - } else if strings.Contains(model, "thinking") { - if !strings.Contains(model, "claude") { - testRequest.MaxTokens = 50 + case "/v1/images/generations": + return &dto.ImageRequest{ + Model: model, + Prompt: "a cute cat", + N: 1, + Size: "1024x1024", + } + default: + return &dto.OpenAIResponsesRequest{ + Model: model, + Input: json.RawMessage(`[{"role":"user","content":"hi"}]`), + MaxOutputTokens: 10, + Stream: false, } - } else if strings.Contains(model, "gemini") { - testRequest.MaxTokens = 3000 - } else { - testRequest.MaxTokens = 10 } - - return testRequest } func TestChannel(c *gin.Context) { diff --git a/controller/playground.go b/controller/playground.go index 501c4e15657..78b09d9304d 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -4,9 +4,10 @@ import ( "errors" "fmt" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" - relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -29,13 +30,11 @@ func Playground(c *gin.Context) { return } - relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAI, nil, nil) - if err != nil { - newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) - return - } - userId := c.GetInt("id") + usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + if usingGroup == "" { + usingGroup = common.GetContextKeyString(c, constant.ContextKeyUserGroup) + } // Write user context to ensure acceptUnsetRatio is available userCache, err := model.GetUserCache(userId) @@ -47,10 +46,10 @@ func Playground(c *gin.Context) { tempToken := &model.Token{ UserId: userId, - Name: fmt.Sprintf("playground-%s", relayInfo.UsingGroup), - Group: relayInfo.UsingGroup, + Name: fmt.Sprintf("playground-%s", usingGroup), + Group: usingGroup, } _ = middleware.SetupContextForToken(c, tempToken) - Relay(c, types.RelayFormatOpenAI) + Relay(c, types.RelayFormatOpenAIResponses) } diff --git a/docs/openapi/relay.json b/docs/openapi/relay.json index 166bd85335c..c508fe44f58 100644 --- a/docs/openapi/relay.json +++ b/docs/openapi/relay.json @@ -183,68 +183,6 @@ ] } }, - "/v1/chat/completions": { - "post": { - "summary": "创建聊天对话", - "deprecated": false, - "description": "根据对话历史创建模型响应。支持流式和非流式响应。\n\n兼容 OpenAI Chat Completions API。\n", - "operationId": "createChatCompletion", - "tags": [ - "OpenAI格式(Chat)" - ], - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChatCompletionRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "成功创建响应", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChatCompletionResponse" - } - } - }, - "headers": {} - }, - "400": { - "description": "请求参数错误", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "headers": {} - }, - "429": { - "description": "请求频率限制", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "headers": {} - } - }, - "security": [ - { - "BearerAuth": [] - } - ] - } - }, "/v1/responses": { "post": { "summary": "创建响应 (OpenAI Responses API)", @@ -1511,46 +1449,6 @@ ] } }, - "/v1/completions": { - "post": { - "summary": "创建文本补全", - "deprecated": false, - "description": "基于给定提示创建文本补全", - "operationId": "createCompletion", - "tags": [ - "文本补全(Completions)" - ], - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CompletionRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "成功创建响应", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CompletionResponse" - } - } - }, - "headers": {} - } - }, - "security": [ - { - "BearerAuth": [] - } - ] - } - }, "/v1/audio/transcriptions": { "post": { "summary": "音频转录", @@ -7138,4 +7036,4 @@ "BearerAuth": [] } ] -} \ No newline at end of file +} diff --git a/dto/error.go b/dto/error.go index cf00d6772ac..8fa52476f49 100644 --- a/dto/error.go +++ b/dto/error.go @@ -22,6 +22,7 @@ type OpenAIErrorWithStatusCode struct { type GeneralErrorResponse struct { Error json.RawMessage `json:"error"` + Detail string `json:"detail"` Message string `json:"message"` Msg string `json:"msg"` Err string `json:"err"` @@ -56,6 +57,9 @@ func (e GeneralErrorResponse) ToMessage() string { if err == nil && openAIError.Message != "" { return openAIError.Message } + // Some upstreams return an object without a "message" field. + // Falling back to the raw JSON is better than returning an empty string. + return string(e.Error) case "string": var msg string err := common.Unmarshal(e.Error, &msg) @@ -69,6 +73,9 @@ func (e GeneralErrorResponse) ToMessage() string { if e.Message != "" { return e.Message } + if e.Detail != "" { + return e.Detail + } if e.Msg != "" { return e.Msg } diff --git a/dto/error_test.go b/dto/error_test.go new file mode 100644 index 00000000000..cb69096e78b --- /dev/null +++ b/dto/error_test.go @@ -0,0 +1,43 @@ +package dto + +import ( + "encoding/json" + "testing" +) + +func TestGeneralErrorResponse_ToMessage_ObjectWithoutMessageFallsBackToRaw(t *testing.T) { + resp := GeneralErrorResponse{ + Error: json.RawMessage(`{"code":"model_not_found"}`), + } + if got, want := resp.ToMessage(), `{"code":"model_not_found"}`; got != want { + t.Fatalf("ToMessage() = %q, want %q", got, want) + } +} + +func TestGeneralErrorResponse_ToMessage_ObjectWithMessageUsesMessage(t *testing.T) { + resp := GeneralErrorResponse{ + Error: json.RawMessage(`{"message":"nope","type":"invalid_request_error"}`), + } + if got, want := resp.ToMessage(), "nope"; got != want { + t.Fatalf("ToMessage() = %q, want %q", got, want) + } +} + +func TestGeneralErrorResponse_ToMessage_StringErrorUsesString(t *testing.T) { + resp := GeneralErrorResponse{ + Error: json.RawMessage(`"nope"`), + } + if got, want := resp.ToMessage(), "nope"; got != want { + t.Fatalf("ToMessage() = %q, want %q", got, want) + } +} + +func TestGeneralErrorResponse_ToMessage_DetailUsesDetail(t *testing.T) { + var resp GeneralErrorResponse + if err := json.Unmarshal([]byte(`{"detail":"Unsupported parameter: messages"}`), &resp); err != nil { + t.Fatalf("json.Unmarshal() error: %v", err) + } + if got, want := resp.ToMessage(), "Unsupported parameter: messages"; got != want { + t.Fatalf("ToMessage() = %q, want %q", got, want) + } +} diff --git a/middleware/distributor.go b/middleware/distributor.go index a33404726b3..c64c00ad427 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -80,8 +80,8 @@ func Distribute() func(c *gin.Context) { } var selectGroup string usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) - // check path is /pg/chat/completions - if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { + // check path is /pg/responses + if strings.HasPrefix(c.Request.URL.Path, "/pg/responses") { playgroundRequest := &dto.PlayGroundRequest{} err = common.UnmarshalBodyReusable(c, playgroundRequest) if err != nil { @@ -290,8 +290,8 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { } c.Set("relay_mode", relayMode) } - if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { - // playground chat completions + if strings.HasPrefix(c.Request.URL.Path, "/pg/responses") { + // playground responses req, err := getModelFromRequest(c) if err != nil { return nil, false, err diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 85a1b9c5f88..149730506dd 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -54,7 +54,9 @@ const ( func Path2RelayMode(path string) int { relayMode := RelayModeUnknown - if strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/pg/chat/completions") { + if strings.HasPrefix(path, "/v1/responses") || strings.HasPrefix(path, "/pg/responses") { + relayMode = RelayModeResponses + } else if strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/pg/chat/completions") { relayMode = RelayModeChatCompletions } else if strings.HasPrefix(path, "/v1/completions") { relayMode = RelayModeCompletions @@ -70,8 +72,6 @@ func Path2RelayMode(path string) int { relayMode = RelayModeImagesEdits } else if strings.HasPrefix(path, "/v1/edits") { relayMode = RelayModeEdits - } else if strings.HasPrefix(path, "/v1/responses") { - relayMode = RelayModeResponses } else if strings.HasPrefix(path, "/v1/audio/speech") { relayMode = RelayModeAudioSpeech } else if strings.HasPrefix(path, "/v1/audio/transcriptions") { diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index 3bdfa6ff493..43d89680a88 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -119,9 +119,6 @@ func GetAndValidateResponsesRequest(c *gin.Context) (*dto.OpenAIResponsesRequest if request.Model == "" { return nil, errors.New("model is required") } - if request.Input == nil { - return nil, errors.New("input is required") - } return request, nil } diff --git a/relay/helper/valid_request_test.go b/relay/helper/valid_request_test.go new file mode 100644 index 00000000000..0447ec6a279 --- /dev/null +++ b/relay/helper/valid_request_test.go @@ -0,0 +1,41 @@ +package helper + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/gin-gonic/gin" +) + +func TestGetAndValidateResponsesRequest_AllowsMissingInput(t *testing.T) { + constant.MaxRequestBodyMB = 20 + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + c.Request = &http.Request{ + Method: "POST", + URL: nil, + Body: io.NopCloser(bytes.NewBufferString(`{"model":"gpt-4o-mini"}`)), + Header: make(http.Header), + } + c.Request.Header.Set("Content-Type", "application/json") + + req, err := GetAndValidateResponsesRequest(c) + if err != nil { + t.Fatalf("GetAndValidateResponsesRequest() error: %v", err) + } + if req == nil { + t.Fatal("GetAndValidateResponsesRequest() returned nil request") + } + if req.Model != "gpt-4o-mini" { + t.Fatalf("Model = %q, want %q", req.Model, "gpt-4o-mini") + } + if req.Input != nil { + t.Fatalf("Input = %v, want nil", req.Input) + } +} diff --git a/router/relay-router.go b/router/relay-router.go index 267459e2c14..76fa1f215b6 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -58,7 +58,7 @@ func SetRelayRouter(router *gin.Engine) { playgroundRouter := router.Group("/pg") playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute()) { - playgroundRouter.POST("/chat/completions", controller.Playground) + playgroundRouter.POST("/responses", controller.Playground) } relayV1Router := router.Group("/v1") relayV1Router.Use(middleware.TokenAuth()) @@ -82,13 +82,6 @@ func SetRelayRouter(router *gin.Engine) { }) // chat related routes - httpRouter.POST("/completions", func(c *gin.Context) { - controller.Relay(c, types.RelayFormatOpenAI) - }) - httpRouter.POST("/chat/completions", func(c *gin.Context) { - controller.Relay(c, types.RelayFormatOpenAI) - }) - // response related routes httpRouter.POST("/responses", func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIResponses) diff --git a/service/error.go b/service/error.go index 9e517e85aca..4b1daf2c69f 100644 --- a/service/error.go +++ b/service/error.go @@ -84,8 +84,15 @@ func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.Claude func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + if resp.Body == nil { + newApiErr.Err = errors.New("response body is nil") + return + } + responseBody, err := io.ReadAll(resp.Body) if err != nil { + CloseResponseBodyGracefully(resp) + newApiErr.Err = fmt.Errorf("read response body failed: %w", err) return } CloseResponseBodyGracefully(resp) diff --git a/service/error_test.go b/service/error_test.go new file mode 100644 index 00000000000..3b9edc293fe --- /dev/null +++ b/service/error_test.go @@ -0,0 +1,45 @@ +package service + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" +) + +type errReadCloser struct{} + +func (errReadCloser) Read([]byte) (int, error) { return 0, errors.New("boom") } +func (errReadCloser) Close() error { return nil } + +func TestRelayErrorHandler_ReadBodyErrorHasMessage(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: errReadCloser{}, + } + newApiErr := RelayErrorHandler(context.Background(), resp, true) + if newApiErr == nil { + t.Fatal("RelayErrorHandler() returned nil") + } + if got := newApiErr.Error(); got == "" { + t.Fatal("RelayErrorHandler() returned empty error message") + } + if got := newApiErr.Error(); !strings.Contains(got, "read response body failed") { + t.Fatalf("RelayErrorHandler() message = %q, want to contain %q", got, "read response body failed") + } +} + +func TestRelayErrorHandler_NilBodyHasMessage(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: nil, + } + newApiErr := RelayErrorHandler(context.Background(), resp, true) + if newApiErr == nil { + t.Fatal("RelayErrorHandler() returned nil") + } + if got := newApiErr.Error(); got == "" { + t.Fatal("RelayErrorHandler() returned empty error message") + } +} diff --git a/types/error.go b/types/error.go index 3bfd0399a6c..85b91f21daf 100644 --- a/types/error.go +++ b/types/error.go @@ -124,7 +124,12 @@ func (e *NewAPIError) Error() string { // fallback message when underlying error is missing return string(e.errorCode) } - return e.Err.Error() + msg := e.Err.Error() + if msg == "" { + // Avoid returning an empty string to callers/UI. + return string(e.errorCode) + } + return msg } func (e *NewAPIError) MaskSensitiveError() string { diff --git a/types/error_test.go b/types/error_test.go new file mode 100644 index 00000000000..fb5fee268fb --- /dev/null +++ b/types/error_test.go @@ -0,0 +1,13 @@ +package types + +import "testing" + +func TestNewAPIError_Error_EmptyUnderlyingMessageFallsBackToErrorCode(t *testing.T) { + err := InitOpenAIError(ErrorCodeBadResponseStatusCode, 400) + if err == nil { + t.Fatal("InitOpenAIError() returned nil") + } + if got, want := err.Error(), string(ErrorCodeBadResponseStatusCode); got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } +} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index e5cf66434c6..f53bf1ee0bd 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -108,7 +108,7 @@ function type2secretPrompt(type) { case 33: return '按照如下格式输入:Ak|Sk|Region'; case 45: - return '请输入渠道对应的鉴权密钥, 豆包语音输入:AppId|AccessToken'; + return '请输入渠道对应的鉴权密钥, 豆包语音输入:AppId|AccessToken'; case 50: return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey'; case 51: @@ -197,17 +197,11 @@ const EditChannelModal = (props) => { if (!trimmed) return []; try { const parsed = JSON.parse(trimmed); - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) - ) { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return []; } const values = Object.values(parsed) - .map((value) => - typeof value === 'string' ? value.trim() : undefined, - ) + .map((value) => (typeof value === 'string' ? value.trim() : undefined)) .filter((value) => value); return Array.from(new Set(values)); } catch (error) { @@ -1684,7 +1678,10 @@ const EditChannelModal = (props) => { style={{ width: '100%' }} value={inputs.aws_key_type || 'ak_sk'} onChange={(value) => { - handleChannelOtherSettingsChange('aws_key_type', value); + handleChannelOtherSettingsChange( + 'aws_key_type', + value, + ); }} extraText={t( 'AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key', @@ -1764,7 +1761,9 @@ const EditChannelModal = (props) => { placeholder={ inputs.type === 33 ? inputs.aws_key_type === 'api_key' - ? t('请输入 API Key,一行一个,格式:APIKey|Region') + ? t( + '请输入 API Key,一行一个,格式:APIKey|Region', + ) : t( '请输入密钥,一行一个,格式:AccessKey|SecretAccessKey|Region', ) @@ -1967,7 +1966,9 @@ const EditChannelModal = (props) => { inputs.type === 33 ? inputs.aws_key_type === 'api_key' ? t('请输入 API Key,格式:APIKey|Region') - : t('按照如下格式输入:AccessKey|SecretAccessKey|Region') + : t( + '按照如下格式输入:AccessKey|SecretAccessKey|Region', + ) : t(type2secretPrompt(inputs.type)) } rules={ @@ -2257,7 +2258,7 @@ const EditChannelModal = (props) => { field='base_url' label={t('完整的 Base URL,支持变量{model}')} placeholder={t( - '请输入完整的URL,例如:https://api.openai.com/v1/chat/completions', + '请输入完整的URL,例如:https://api.openai.com/v1/responses', )} onChange={(value) => handleInputChange('base_url', value) @@ -2355,10 +2356,10 @@ const EditChannelModal = (props) => { label: 'https://ark.ap-southeast.bytepluses.com', }, - { - value: 'doubao-coding-plan', - label: 'Doubao Coding Plan', - }, + { + value: 'doubao-coding-plan', + label: 'Doubao Coding Plan', + }, ]} defaultValue='https://ark.cn-beijing.volces.com' /> diff --git a/web/src/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx index 1879cd574ae..a59ba6ac81f 100644 --- a/web/src/components/table/channels/modals/ModelTestModal.jsx +++ b/web/src/components/table/channels/modals/ModelTestModal.jsx @@ -64,8 +64,7 @@ const ModelTestModal = ({ const endpointTypeOptions = [ { value: '', label: t('自动检测') }, - { value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, - { value: 'openai-response', label: 'OpenAI Response (/v1/responses)' }, + { value: 'openai', label: 'OpenAI (/v1/responses)' }, { value: 'anthropic', label: 'Anthropic (/v1/messages)' }, { value: 'gemini', diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index 17b7d0c7026..9bf4b9ea60d 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -42,8 +42,7 @@ const { Text, Title } = Typography; // Example endpoint template for quick fill const ENDPOINT_TEMPLATE = { - openai: { path: '/v1/chat/completions', method: 'POST' }, - 'openai-response': { path: '/v1/responses', method: 'POST' }, + openai: { path: '/v1/responses', method: 'POST' }, anthropic: { path: '/v1/messages', method: 'POST' }, gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' }, 'jina-rerank': { path: '/rerank', method: 'POST' }, @@ -449,7 +448,7 @@ const EditModelModal = (props) => { field='endpoints' label={t('端点映射')} placeholder={ - '{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}' + '{\n "openai": {"path": "/v1/responses", "method": "POST"}\n}' } value={values.endpoints} onChange={(val) => diff --git a/web/src/components/table/models/modals/EditPrefillGroupModal.jsx b/web/src/components/table/models/modals/EditPrefillGroupModal.jsx index 1ca5e64726c..f0011fec4eb 100644 --- a/web/src/components/table/models/modals/EditPrefillGroupModal.jsx +++ b/web/src/components/table/models/modals/EditPrefillGroupModal.jsx @@ -41,8 +41,7 @@ const { Text, Title } = Typography; // Example endpoint template for quick fill const ENDPOINT_TEMPLATE = { - openai: { path: '/v1/chat/completions', method: 'POST' }, - 'openai-response': { path: '/v1/responses', method: 'POST' }, + openai: { path: '/v1/responses', method: 'POST' }, anthropic: { path: '/v1/messages', method: 'POST' }, gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' }, 'jina-rerank': { path: '/rerank', method: 'POST' }, @@ -245,7 +244,7 @@ const EditPrefillGroupModal = ({ } editorType='object' placeholder={ - '{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}' + '{\n "openai": {"path": "/v1/responses", "method": "POST"}\n}' } template={ENDPOINT_TEMPLATE} templateLabel={t('填入模板')} diff --git a/web/src/constants/common.constant.js b/web/src/constants/common.constant.js index a142a0eb57a..9254eec3590 100644 --- a/web/src/constants/common.constant.js +++ b/web/src/constants/common.constant.js @@ -24,7 +24,6 @@ export const DEFAULT_ENDPOINT = '/api/ratio_config'; export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes'; export const API_ENDPOINTS = [ - '/v1/chat/completions', '/v1/responses', '/v1/messages', '/v1beta/models', diff --git a/web/src/constants/playground.constants.js b/web/src/constants/playground.constants.js index 9ba88621cbd..90e669df878 100644 --- a/web/src/constants/playground.constants.js +++ b/web/src/constants/playground.constants.js @@ -75,7 +75,7 @@ export const DEBUG_TABS = { // ========== API 相关常量 ========== export const API_ENDPOINTS = { - CHAT_COMPLETIONS: '/pg/chat/completions', + RESPONSES: '/pg/responses', USER_MODELS: '/api/user/models', USER_GROUPS: '/api/user/self/groups', }; diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 6e09bf43ca9..56bff107998 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -114,10 +114,33 @@ export const buildApiPayload = ( }); } + const toResponsesContent = (content) => { + if (!Array.isArray(content)) return content; + + return content + .map((item) => { + if (!item || typeof item !== 'object') return null; + + if (item.type === 'text') { + return { type: 'input_text', text: item.text || '' }; + } + if (item.type === 'image_url') { + const url = item.image_url?.url; + if (!url) return null; + return { type: 'input_image', image_url: url }; + } + return null; + }) + .filter(Boolean); + }; + const payload = { model: inputs.model, group: inputs.group, - messages: processedMessages, + input: processedMessages.map((m) => ({ + role: m.role, + content: toResponsesContent(m.content), + })), stream: inputs.stream, }; @@ -125,15 +148,12 @@ export const buildApiPayload = ( const parameterMappings = { temperature: 'temperature', top_p: 'top_p', - max_tokens: 'max_tokens', - frequency_penalty: 'frequency_penalty', - presence_penalty: 'presence_penalty', - seed: 'seed', + max_tokens: 'max_output_tokens', }; Object.entries(parameterMappings).forEach(([key, param]) => { const enabled = parameterEnabled[key]; - const value = inputs[param]; + const value = inputs[key]; const hasValue = value !== undefined && value !== null; if (enabled && hasValue) { @@ -236,9 +256,7 @@ async function prepareOAuthState(options = {}) { if (shouldLogout) { try { await API.get('/api/user/logout', { skipErrorHandler: true }); - } catch (err) { - - } + } catch (err) {} localStorage.removeItem('user'); updateAPI(); } diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index 12db9f5cad9..944f9f6cd03 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -185,7 +185,7 @@ export const useApiRequest = ( setActiveDebugTab(DEBUG_TABS.REQUEST); try { - const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, { + const response = await fetch(API_ENDPOINTS.RESPONSES, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -228,12 +228,21 @@ export const useApiRequest = ( })); setActiveDebugTab(DEBUG_TABS.RESPONSE); - if (data.choices?.[0]) { - const choice = data.choices[0]; - let content = choice.message?.content || ''; - let reasoningContent = choice.message?.reasoning_content || choice.message?.reasoning || ''; - - const processed = processThinkTags(content, reasoningContent); + const outputText = + typeof data.output_text === 'string' + ? data.output_text + : Array.isArray(data.output) + ? data.output + .flatMap((item) => + Array.isArray(item?.content) ? item.content : [], + ) + .filter((item) => item && typeof item.text === 'string') + .map((item) => item.text) + .join('') + : ''; + + if (outputText) { + const processed = processThinkTags(outputText, ''); setMessage((prevMessage) => { const newMessages = [...prevMessage]; @@ -298,7 +307,7 @@ export const useApiRequest = ( })); setActiveDebugTab(DEBUG_TABS.REQUEST); - const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, { + const source = new SSE(API_ENDPOINTS.RESPONSES, { headers: { 'Content-Type': 'application/json', 'New-Api-User': getUserIdFromLocalStorage(), @@ -313,47 +322,79 @@ export const useApiRequest = ( let hasReceivedFirstResponse = false; let isStreamComplete = false; // 添加标志位跟踪流是否正常完成 - source.addEventListener('message', (e) => { - if (e.data === '[DONE]') { - isStreamComplete = true; // 标记流正常完成 + const handleStreamEvent = (e) => { + const eventType = e.type || 'message'; + const rawData = e.data || ''; + + // legacy fallback (shouldn't happen for Responses API, but harmless) + if (rawData === '[DONE]') { + isStreamComplete = true; source.close(); sseSourceRef.current = null; - setDebugData((prev) => ({ - ...prev, + setDebugData((prev) => ({ + ...prev, response: responseData, - sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记 + sseMessages: [...(prev.sseMessages || []), '[DONE]'], isStreaming: false, })); completeMessage(); return; } + responseData += `${eventType}: ${rawData}\n`; + + if (!hasReceivedFirstResponse) { + setActiveDebugTab(DEBUG_TABS.RESPONSE); + hasReceivedFirstResponse = true; + } + + setDebugData((prev) => ({ + ...prev, + sseMessages: [ + ...(prev.sseMessages || []), + `${eventType}: ${rawData}`, + ], + })); + try { - const payload = JSON.parse(e.data); - responseData += e.data + '\n'; + const data = JSON.parse(rawData); - if (!hasReceivedFirstResponse) { - setActiveDebugTab(DEBUG_TABS.RESPONSE); - hasReceivedFirstResponse = true; + if (eventType === 'response.output_text.delta') { + if (typeof data.delta === 'string') { + streamMessageUpdate(data.delta, 'content'); + } + return; } - // 新增:将 SSE 消息添加到数组 - setDebugData((prev) => ({ - ...prev, - sseMessages: [...(prev.sseMessages || []), e.data], - })); + if (eventType === 'response.completed') { + isStreamComplete = true; + source.close(); + sseSourceRef.current = null; + setDebugData((prev) => ({ + ...prev, + response: responseData, + isStreaming: false, + })); + completeMessage(); + return; + } - const delta = payload.choices?.[0]?.delta; - if (delta) { - if (delta.reasoning_content) { - streamMessageUpdate(delta.reasoning_content, 'reasoning'); - } - if (delta.reasoning) { - streamMessageUpdate(delta.reasoning, 'reasoning'); - } - if (delta.content) { - streamMessageUpdate(delta.content, 'content'); - } + if ( + eventType === 'response.failed' || + eventType === 'response.incomplete' + ) { + const errorMessage = + data?.response?.error?.message || t('请求发生错误'); + isStreamComplete = true; + source.close(); + sseSourceRef.current = null; + setDebugData((prev) => ({ + ...prev, + response: responseData, + isStreaming: false, + })); + streamMessageUpdate(errorMessage, 'content'); + completeMessage(MESSAGE_STATUS.ERROR); } } catch (error) { console.error('Failed to parse SSE message:', error); @@ -362,7 +403,10 @@ export const useApiRequest = ( setDebugData((prev) => ({ ...prev, response: responseData + `\n\nError: ${errorInfo}`, - sseMessages: [...(prev.sseMessages || []), e.data], // 即使解析失败也保存原始数据 + sseMessages: [ + ...(prev.sseMessages || []), + `${eventType}: ${rawData}`, + ], isStreaming: false, })); setActiveDebugTab(DEBUG_TABS.RESPONSE); @@ -370,7 +414,24 @@ export const useApiRequest = ( streamMessageUpdate(t('解析响应数据时发生错误'), 'content'); completeMessage(MESSAGE_STATUS.ERROR); } + }; + + const responseEventTypes = [ + 'response.created', + 'response.in_progress', + 'response.output_item.added', + 'response.output_item.done', + 'response.output_text.delta', + 'response.output_text.done', + 'response.completed', + 'response.incomplete', + 'response.failed', + ]; + + responseEventTypes.forEach((type) => { + source.addEventListener(type, handleStreamEvent); }); + source.addEventListener('message', handleStreamEvent); source.addEventListener('error', (e) => { // 只有在流没有正常完成且连接状态异常时才处理错误 @@ -440,14 +501,7 @@ export const useApiRequest = ( completeMessage(MESSAGE_STATUS.ERROR); } }, - [ - setDebugData, - setActiveDebugTab, - streamMessageUpdate, - completeMessage, - t, - applyAutoCollapseLogic, - ], + [setDebugData, setActiveDebugTab, streamMessageUpdate, completeMessage, t], ); // 停止生成 diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 4de684048c6..cf79b3cdd8b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1831,7 +1831,7 @@ "请输入填充值": "Please enter a value", "请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)", "请输入完整的 JSON 格式密钥内容": "Please enter the complete JSON format key content", - "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions": "Please enter complete URL, e.g.: https://api.openai.com/v1/chat/completions", + "请输入完整的URL,例如:https://api.openai.com/v1/responses": "Please enter complete URL, e.g.: https://api.openai.com/v1/responses", "请输入完整的URL链接": "Please enter the complete URL link", "请输入密码": "Please enter password", "请输入密钥": "Please enter the key", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index d05cdf5698c..e64be3b7315 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1841,7 +1841,7 @@ "请输入填充值": "Veuillez saisir une valeur", "请输入备注(仅管理员可见)": "Veuillez saisir une remarque (visible uniquement par les administrateurs)", "请输入完整的 JSON 格式密钥内容": "Veuillez saisir le contenu complet de la clé au format JSON", - "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions": "Veuillez saisir l'URL complète, par exemple : https://api.openai.com/v1/chat/completions", + "请输入完整的URL,例如:https://api.openai.com/v1/responses": "Veuillez saisir l'URL complète, par exemple : https://api.openai.com/v1/responses", "请输入完整的URL链接": "Veuillez saisir le lien URL complet", "请输入密码": "Veuillez saisir un mot de passe", "请输入密钥": "Veuillez saisir la clé", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 2b3ea9f0255..f0903af03b5 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -1743,7 +1743,7 @@ "请输入填充值": "値を入力してください", "请输入备注(仅管理员可见)": "備考を入力してください(管理者のみ閲覧可能です)", "请输入完整的 JSON 格式密钥内容": "完全なJSON形式のAPIキーを入力してください", - "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions": "完全なURLを入力してください(例:https://api.openai.com/v1/chat/completions)", + "请输入完整的URL,例如:https://api.openai.com/v1/responses": "完全なURLを入力してください(例:https://api.openai.com/v1/responses)", "请输入完整的URL链接": "完全なURLを入力してください", "请输入密码": "パスワードを入力してください", "请输入密钥": "APIキーを入力してください", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 76616cbdbba..893eabe0023 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1852,7 +1852,7 @@ "请输入填充值": "Пожалуйста, введите значение заполнения", "请输入备注(仅管理员可见)": "Пожалуйста, введите примечание (видимо только администратору)", "请输入完整的 JSON 格式密钥内容": "Пожалуйста, введите полное содержимое ключа в формате JSON", - "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions": "Пожалуйста, введите полный URL, например: https://api.openai.com/v1/chat/completions", + "请输入完整的URL,例如:https://api.openai.com/v1/responses": "Пожалуйста, введите полный URL, например: https://api.openai.com/v1/responses", "请输入完整的URL链接": "Пожалуйста, введите полную URL-ссылку", "请输入密码": "Пожалуйста, введите пароль", "请输入密钥": "Пожалуйста, введите ключ", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 556501da2f9..1c7a88de2ab 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -2054,7 +2054,7 @@ "请输入填充值": "Vui lòng nhập giá trị điền", "请输入备注(仅管理员可见)": "Vui lòng nhập ghi chú (chỉ quản trị viên mới thấy)", "请输入完整的 JSON 格式密钥内容": "Vui lòng nhập nội dung khóa định dạng JSON đầy đủ", - "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions": "Vui lòng nhập URL đầy đủ, ví dụ: https://api.openai.com/v1/chat/completions", + "请输入完整的URL,例如:https://api.openai.com/v1/responses": "Vui lòng nhập URL đầy đủ, ví dụ: https://api.openai.com/v1/responses", "请输入完整的URL链接": "Vui lòng nhập liên kết URL đầy đủ", "请输入密钥,一行一个": "Vui lòng nhập khóa, mỗi dòng một cái", "请输入密钥!": "Vui lòng nhập khóa!", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a8d28acca18..41454edafb6 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1819,7 +1819,7 @@ "请输入填充值": "请输入填充值", "请输入备注(仅管理员可见)": "请输入备注(仅管理员可见)", "请输入完整的 JSON 格式密钥内容": "请输入完整的 JSON 格式密钥内容", - "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions": "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions", + "请输入完整的URL,例如:https://api.openai.com/v1/responses": "请输入完整的URL,例如:https://api.openai.com/v1/responses", "请输入完整的URL链接": "请输入完整的URL链接", "请输入密码": "请输入密码", "请输入密钥": "请输入密钥",