From 983cf297da59e3be23b5e3f2c4eded945b53f408 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Fri, 2 Oct 2026 17:16:37 +0800 Subject: [PATCH 1/5] fix(gateway): retry rejected structured input through chat endpoint --- .../openai_gateway_chat_completions.go | 37 +++ .../openai_gateway_chat_completions_test.go | 286 ++++++++++++++++++ 2 files changed, 323 insertions(+) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a94d570078..8192ebb6dc 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "regexp" "strings" "sync/atomic" "time" @@ -431,6 +432,18 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( ) return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } + responsesMode, _ := account.Extra[openai_compat.ExtraKeyResponsesMode].(string) + if account.IsOpenAIApiKey() && + openai_compat.NormalizeResponsesSupportMode(responsesMode) == openai_compat.ResponsesSupportModeAuto && + openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportYes && + !isResponsesShape && + isConvertedResponsesInputStringRejection(resp.StatusCode, respBody) { + logger.L().Info("openai chat_completions: structured Responses input rejected, falling back to raw chat completions", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", resp.StatusCode), + ) + return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + } if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { return nil, foErr } @@ -483,6 +496,30 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( return result, handleErr } +var responsesInputStringValidationError = regexp.MustCompile(`['"]loc['"]\s*:\s*\(\s*['"]body['"]\s*,\s*['"]input['"]\s*,\s*['"]str['"]\s*\)\s*,\s*['"]msg['"]\s*:\s*['"]Input should be a valid string['"]`) + +func isConvertedResponsesInputStringRejection(status int, responseBody []byte) bool { + if status != http.StatusBadRequest { + return false + } + message := gjson.GetBytes(responseBody, "error.message").String() + if responsesInputStringValidationError.MatchString(message) { + return true + } + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.code").String())) + typ := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.type").String())) + if code != "invalid_type" && typ != "invalid_type" { + return false + } + param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String())) + if param != "input" { + return false + } + message = strings.ToLower(strings.TrimSpace(message)) + return strings.Contains(message, "expected a string") && + (strings.Contains(message, "got an array") || strings.Contains(message, "got an object")) +} + func normalizeResponsesRequestServiceTier(req *apicompat.ResponsesRequest) { if req == nil { return diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 5ff9a55e6b..5d8a04831b 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -13,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -32,6 +34,290 @@ func (w *openAIChatFailingWriter) Write(p []byte) (int, error) { return w.ResponseWriter.Write(p) } +func TestForwardAsChatCompletions_ResponsesSupportedFallsBackWhenStructuredInputRequiresString(t *testing.T) { + for name, rejection := range map[string]string{ + "OpenAI invalid type": `{"error":{"type":"invalid_request_error","code":"invalid_type","param":"input","message":"Invalid type for 'input': expected a string, but got an array instead."}}`, + "vLLM validation": `{"error":{"message":"240 validation errors: ... 'loc':('body','input','str'), 'msg':'Input should be a valid string', 'input':[{'role':'user','content':'hi'}, {'role':'assistant','content':[{'type':'output_text'}]}, ResponseFunctionToolCall(type='function_call'), {'type':'function_call_output'}]"}}`, + } { + t.Run(name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"},{"role":"assistant","content":[{"type":"text","text":"checking"},{"type":"thinking","thinking":"think"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"done"},{"role":"user","content":"what now"}],"stream":false}`) + originalBody := append([]byte(nil), body...) + logs, releaseLogs := captureStructuredLog(t) + defer releaseLogs() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + rejection, + )), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_raw_fallback"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_fallback","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, + )), + }, + }} + svc := &OpenAIGatewayService{ + cfg: structuredInputRetryTestConfig(), + httpUpstream: upstream, + } + account := structuredInputRetryTestAccount() + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requests, 2) + require.Equal(t, "/v1/responses", upstream.requests[0].URL.Path) + require.True(t, gjson.GetBytes(upstream.bodies[0], "input").IsArray()) + require.Equal(t, "/v1/chat/completions", upstream.requests[1].URL.Path) + require.JSONEq(t, string(body), string(upstream.bodies[1])) + require.Equal(t, originalBody, body) + require.Equal(t, "/v1/chat/completions", result.UpstreamEndpoint) + require.Equal(t, "/v1/chat/completions", GetActualOpenAIUpstreamEndpoint(c)) + require.Equal(t, "ok", gjson.Get(rec.Body.String(), "choices.0.message.content").String()) + require.True(t, logs.ContainsMessage("structured Responses input rejected")) + logs.mu.Lock() + defer logs.mu.Unlock() + for _, event := range logs.events { + require.NotContains(t, event.Fields, "upstream_message") + require.NotContains(t, event.Fields, "upstream_body") + require.NotContains(t, event.Message, rejection) + } + }) + } +} + +func TestConvertedResponsesInputStringRejectionIsNarrow(t *testing.T) { + matching := []byte(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string for input, but got an object instead."}}`) + require.True(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, matching)) + + for _, tc := range []struct { + name string + status int + body string + }{ + {name: "nested content validation", status: http.StatusBadRequest, body: `{"error":{"message":"'loc':('body','input',0,'content','str'), 'msg':'Input should be a valid string'"}}`}, + {name: "other field validation", status: http.StatusBadRequest, body: `{"error":{"message":"'loc':('body','model','str'), 'msg':'Input should be a valid string'"}}`}, + {name: "generic string validation", status: http.StatusBadRequest, body: `{"error":{"message":"Input should be a valid string"}}`}, + {name: "wrong status", status: http.StatusUnprocessableEntity, body: string(matching)}, + {name: "authentication", status: http.StatusUnauthorized, body: string(matching)}, + {name: "authorization", status: http.StatusForbidden, body: string(matching)}, + {name: "rate limit", status: http.StatusTooManyRequests, body: string(matching)}, + {name: "server error", status: http.StatusInternalServerError, body: string(matching)}, + {name: "wrong parameter", status: http.StatusBadRequest, body: `{"error":{"code":"invalid_type","param":"tools","message":"Expected a string, but got an array instead."}}`}, + {name: "wrong code", status: http.StatusBadRequest, body: `{"error":{"code":"invalid_request_error","param":"input","message":"Expected a string, but got an array instead."}}`}, + {name: "unrelated input error", status: http.StatusBadRequest, body: `{"error":{"code":"invalid_type","param":"input","message":"Input is too long."}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + require.False(t, isConvertedResponsesInputStringRejection(tc.status, []byte(tc.body))) + }) + } +} + +func TestForwardAsChatCompletions_StructuredInputRejectionDoesNotRetryIneligibleRequests(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + for _, test := range []struct { + name string + mode string + unknown bool + platform string + accountType string + responsesBody bool + nativeIngress bool + status int + rejection string + }{ + {name: "forced Responses", mode: "force_responses"}, + {name: "forced Responses without probe", mode: "force_responses", unknown: true}, + {name: "unknown support", unknown: true}, + {name: "Responses shaped Chat ingress", responsesBody: true}, + {name: "native Responses ingress", responsesBody: true, nativeIngress: true}, + {name: "native CN Responses protocol", platform: PlatformDeepseek}, + {name: "OAuth", accountType: AccountTypeOAuth}, + {name: "authentication", status: http.StatusUnauthorized}, + {name: "authorization", status: http.StatusForbidden}, + {name: "rate limit", status: http.StatusTooManyRequests}, + {name: "unrelated validation", rejection: `{"error":{"code":"invalid_type","param":"tools","message":"Expected a string, but got an array instead."}}`}, + {name: "nested validation", rejection: `{"error":{"message":"'loc':('body','input',0,'content','str'), 'msg':'Input should be a valid string'"}}`}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + if test.responsesBody { + body = []byte(`{"model":"gpt-5.4","input":[{"role":"user","content":"hello"}],"stream":false}`) + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + account := structuredInputRetryTestAccount() + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: true, openai_compat.ExtraKeyResponsesMode: test.mode} + if test.unknown { + delete(account.Extra, openai_compat.ExtraKeyResponsesSupported) + } + if test.platform != "" { + account.Platform = test.platform + account.Credentials["api_protocol"] = APIProtocolResponses + } + if test.accountType != "" { + account.Type = test.accountType + account.Credentials["access_token"] = "test-token" + } + status := test.status + if status == 0 { + status = http.StatusBadRequest + } + errorBody := rejection + if test.rejection != "" { + errorBody = test.rejection + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(errorBody)), + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + var result *OpenAIForwardResult + var err error + if test.nativeIngress { + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + result, err = svc.Forward(context.Background(), c, account, body) + } else { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + } + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.requests, 1) + require.True(t, strings.HasSuffix(upstream.requests[0].URL.Path, "/responses")) + var failover *UpstreamFailoverError + if status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusTooManyRequests { + require.ErrorAs(t, err, &failover) + require.Equal(t, status, failover.StatusCode) + } else { + require.False(t, errors.As(err, &failover)) + require.True(t, c.Writer.Written()) + } + }) + } +} + +func TestForwardAsChatCompletions_StructuredInputRetryPreservesStreamingAndMapping(t *testing.T) { + for _, useAccountMapping := range []bool{false, true} { + t.Run(fmt.Sprintf("account_mapping=%t", useAccountMapping), func(t *testing.T) { + body := []byte(`{"model":"client-model","messages":[{"role":"user","content":"hello"}],"stream":true,"stream_options":{"include_usage":false},"vendor_option":"preserve"}`) + originalBody := append([]byte(nil), body...) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + account := structuredInputRetryTestAccount() + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: true, openai_compat.ExtraKeyResponsesMode: "auto"} + mappedModel := "gpt-5.4" + if useAccountMapping { + account.Credentials["model_mapping"] = map[string]any{"client-model": mappedModel} + } + stream := "data: {\"id\":\"chatcmpl_retry\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4,\"total_tokens\":13}}\n\ndata: [DONE]\n\n" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusBadRequest, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}`))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(stream))}, + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + defaultMappedModel := mappedModel + if useAccountMapping { + defaultMappedModel = "" + } + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", defaultMappedModel) + require.NoError(t, err) + require.Len(t, upstream.requests, 2) + for _, outbound := range upstream.bodies { + require.Equal(t, mappedModel, gjson.GetBytes(outbound, "model").String()) + require.True(t, gjson.GetBytes(outbound, "stream").Bool()) + } + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(upstream.bodies[1], "messages").Raw) + require.Equal(t, "preserve", gjson.GetBytes(upstream.bodies[1], "vendor_option").String()) + require.True(t, gjson.GetBytes(upstream.bodies[1], "stream_options.include_usage").Bool()) + require.Equal(t, originalBody, body) + require.True(t, result.Stream) + require.Equal(t, mappedModel, result.BillingModel) + require.Equal(t, mappedModel, result.UpstreamModel) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) + require.Equal(t, "/v1/chat/completions", result.UpstreamEndpoint) + require.Contains(t, rec.Header().Get("Content-Type"), "text/event-stream") + require.Contains(t, rec.Body.String(), "data: [DONE]") + }) + } +} + +func TestForwardAsChatCompletions_StructuredInputRetryIsBoundedAndPreservesRawErrors(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusTooManyRequests} { + t.Run(fmt.Sprintf("status=%d", status), func(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + response := func(status int) *http.Response { + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(rejection))} + } + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + account := structuredInputRetryTestAccount() + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: true} + forward := func(retry bool) (*httptest.ResponseRecorder, error) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{responses: []*http.Response{response(status)}} + if retry { + upstream.responses = append([]*http.Response{response(http.StatusBadRequest)}, upstream.responses...) + } + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + var result *OpenAIForwardResult + var err error + if retry { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.Len(t, upstream.requests, 2) + require.Equal(t, "/v1/chat/completions", upstream.requests[1].URL.Path) + } else { + result, err = svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.Len(t, upstream.requests, 1) + } + require.Nil(t, result) + require.Error(t, err) + return rec, err + } + baseline, baselineErr := forward(false) + retried, retryErr := forward(true) + require.Equal(t, baseline.Code, retried.Code) + require.Equal(t, baseline.Body.String(), retried.Body.String()) + require.Equal(t, baselineErr.Error(), retryErr.Error()) + require.IsType(t, baselineErr, retryErr) + }) + } +} + +func structuredInputRetryTestConfig() *config.Config { + return &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{ + Enabled: false, + AllowInsecureHTTP: true, + }}} +} + +func structuredInputRetryTestAccount() *Account { + return &Account{ + ID: 101, + Name: "raw-openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"}, + Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: true}, + } +} + type openAIChatStreamReadErrorCloser struct { payload []byte err error From c15d858eca81b269c41ba3bc161f923b179a860c Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 6 Oct 2026 10:49:15 +0800 Subject: [PATCH 2/5] fix(gateway): preserve Responses semantics in chat fallback --- .../service/openai_gateway_cc_pipeline.go | 17 +- .../openai_gateway_chat_completions.go | 110 +++++++++++- .../openai_gateway_chat_completions_raw.go | 12 +- .../openai_gateway_chat_completions_test.go | 161 +++++++++++++++++- .../service/openai_gateway_forward.go | 4 +- .../openai_gateway_responses_chat_fallback.go | 3 +- ...ai_gateway_responses_chat_fallback_test.go | 29 ++++ 7 files changed, 319 insertions(+), 17 deletions(-) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 0fe504a6f8..4d65374a45 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -67,13 +67,26 @@ func (s *OpenAIGatewayService) newStreamHeaderWriter(c *gin.Context, upstream ht // (下游 handleXxxErrorResponse 需要再次读取),返回原始错误体与脱敏后的 // 上游错误消息。 func (s *OpenAIGatewayService) readOpenAIUpstreamError(resp *http.Response) ([]byte, string) { - respBody := s.readUpstreamErrorBody(resp) + respBody, upstreamMsg, _ := s.readOpenAIUpstreamErrorWithCompleteness(resp) + return respBody, upstreamMsg +} + +func (s *OpenAIGatewayService) readOpenAIUpstreamErrorWithCompleteness(resp *http.Response) ([]byte, string, bool) { + limit := openAIUpstreamErrorBodyReadLimitForConfig(nil) + if s != nil { + limit = openAIUpstreamErrorBodyReadLimitForConfig(s.cfg) + } + respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + complete := readErr == nil && int64(len(respBody)) <= limit + if int64(len(respBody)) > limit { + respBody = respBody[:limit] + } _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - return respBody, upstreamMsg + return respBody, upstreamMsg, complete } // failoverOpenAIUpstreamHTTPError 对 >=400 的上游响应做 failover 判定:命中时 diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 8192ebb6dc..efa048199a 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "regexp" + "strconv" "strings" "sync/atomic" "time" @@ -413,7 +414,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // 8. Handle error response with failover if resp.StatusCode >= 400 { - respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) + respBody, upstreamMsg, completeErrorBody := s.readOpenAIUpstreamErrorWithCompleteness(resp) if !agentIdentityTaskRecoveryWasTried(ctx) && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) { expectedTaskID := account.GetCredential("task_id") if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil { @@ -430,19 +431,20 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( zap.Int("upstream_status", resp.StatusCode), zap.String("upstream_message", upstreamMsg), ) - return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + return s.forwardAsRawChatCompletionsWithStartTime(ctx, c, account, body, defaultMappedModel, startTime) } responsesMode, _ := account.Extra[openai_compat.ExtraKeyResponsesMode].(string) if account.IsOpenAIApiKey() && openai_compat.NormalizeResponsesSupportMode(responsesMode) == openai_compat.ResponsesSupportModeAuto && openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportYes && !isResponsesShape && + completeErrorBody && isConvertedResponsesInputStringRejection(resp.StatusCode, respBody) { logger.L().Info("openai chat_completions: structured Responses input rejected, falling back to raw chat completions", zap.Int64("account_id", account.ID), zap.Int("upstream_status", resp.StatusCode), ) - return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + return s.forwardAsRawChatCompletionsWithStartTime(ctx, c, account, body, defaultMappedModel, startTime) } if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { return nil, foErr @@ -496,14 +498,107 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( return result, handleErr } -var responsesInputStringValidationError = regexp.MustCompile(`['"]loc['"]\s*:\s*\(\s*['"]body['"]\s*,\s*['"]input['"]\s*,\s*['"]str['"]\s*\)\s*,\s*['"]msg['"]\s*:\s*['"]Input should be a valid string['"]`) +var responsesInputStringValidationError = regexp.MustCompile(`^['"]loc['"]\s*:\s*\(\s*['"]body['"]\s*,\s*['"]input['"]\s*,\s*['"]str['"]\s*\)$`) +var responsesStringValidationMessage = regexp.MustCompile(`^['"]msg['"]\s*:\s*['"]Input should be a valid string['"]$`) +var responsesStringValidationType = regexp.MustCompile(`^['"]type['"]\s*:\s*['"]string_type['"]$`) +var responsesValidationErrors = regexp.MustCompile(`(?s)^([1-9][0-9]*) validation errors?(?: for [^\r\n:]+)?:\s*(\[.*\])$`) +var responsesInvalidInputTypeMessage = regexp.MustCompile(`(?i)^(?:Invalid type for ['"]input['"]:\s*)?Expected a string(?: for input)?, but got an (?:array|object) instead\.?$`) + +func isResponsesInputStringValidationMessage(message string) bool { + match := responsesValidationErrors.FindStringSubmatch(strings.TrimSpace(message)) + if match == nil { + return false + } + entries, ok := splitResponsesValidationFields(match[2][1 : len(match[2])-1]) + count, err := strconv.Atoi(match[1]) + if !ok || err != nil || count != len(entries) { + return false + } + matched := false + for _, entry := range entries { + if !strings.HasPrefix(entry, "{") || !strings.HasSuffix(entry, "}") { + return false + } + fields, ok := splitResponsesValidationFields(entry[1 : len(entry)-1]) + if !ok { + return false + } + location, message, stringType := false, false, false + keys := make(map[string]bool, len(fields)) + for _, field := range fields { + key, value, found := strings.Cut(field, ":") + key = strings.TrimSpace(key) + if !found || len(key) < 3 || (key[0] != '\'' && key[0] != '"') || key[len(key)-1] != key[0] || strings.TrimSpace(value) == "" || keys[key[1:len(key)-1]] { + return false + } + keys[key[1:len(key)-1]] = true + location = location || responsesInputStringValidationError.MatchString(field) + message = message || responsesStringValidationMessage.MatchString(field) + stringType = stringType || responsesStringValidationType.MatchString(field) + } + matched = matched || (location && message && stringType) + } + return matched +} + +func splitResponsesValidationFields(value string) ([]string, bool) { + var fields []string + var brackets []byte + var quote byte + escaped := false + start := 0 + for index := 0; index < len(value); index++ { + character := value[index] + if quote != 0 { + if escaped { + escaped = false + } else if character == '\\' { + escaped = true + } else if character == quote { + quote = 0 + } + continue + } + switch character { + case '\'', '"': + quote = character + case '{', '[', '(': + brackets = append(brackets, character) + case '}', ']', ')': + if len(brackets) == 0 { + return nil, false + } + opening := brackets[len(brackets)-1] + if (character == '}' && opening != '{') || (character == ']' && opening != '[') || (character == ')' && opening != '(') { + return nil, false + } + brackets = brackets[:len(brackets)-1] + case ',': + if len(brackets) == 0 { + field := strings.TrimSpace(value[start:index]) + if field == "" { + return nil, false + } + fields = append(fields, field) + start = index + 1 + } + } + } + if quote != 0 || len(brackets) != 0 { + return nil, false + } + if field := strings.TrimSpace(value[start:]); field != "" { + fields = append(fields, field) + } + return fields, len(fields) > 0 +} func isConvertedResponsesInputStringRejection(status int, responseBody []byte) bool { - if status != http.StatusBadRequest { + if status != http.StatusBadRequest || !json.Valid(responseBody) { return false } message := gjson.GetBytes(responseBody, "error.message").String() - if responsesInputStringValidationError.MatchString(message) { + if isResponsesInputStringValidationMessage(message) { return true } code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.code").String())) @@ -516,8 +611,7 @@ func isConvertedResponsesInputStringRejection(status int, responseBody []byte) b return false } message = strings.ToLower(strings.TrimSpace(message)) - return strings.Contains(message, "expected a string") && - (strings.Contains(message, "got an array") || strings.Contains(message, "got an object")) + return responsesInvalidInputTypeMessage.MatchString(message) } func normalizeResponsesRequestServiceTier(req *apicompat.ResponsesRequest) { diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 25005986a5..98fa8c4a0d 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -61,7 +61,17 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( body []byte, defaultMappedModel string, ) (*OpenAIForwardResult, error) { - startTime := time.Now() + return s.forwardAsRawChatCompletionsWithStartTime(ctx, c, account, body, defaultMappedModel, time.Now()) +} + +func (s *OpenAIGatewayService) forwardAsRawChatCompletionsWithStartTime( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, + startTime time.Time, +) (*OpenAIForwardResult, error) { // 1. Parse minimal fields needed for routing/billing originalModel := gjson.GetBytes(body, "model").String() diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 5d8a04831b..198edc0645 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -37,7 +38,7 @@ func (w *openAIChatFailingWriter) Write(p []byte) (int, error) { func TestForwardAsChatCompletions_ResponsesSupportedFallsBackWhenStructuredInputRequiresString(t *testing.T) { for name, rejection := range map[string]string{ "OpenAI invalid type": `{"error":{"type":"invalid_request_error","code":"invalid_type","param":"input","message":"Invalid type for 'input': expected a string, but got an array instead."}}`, - "vLLM validation": `{"error":{"message":"240 validation errors: ... 'loc':('body','input','str'), 'msg':'Input should be a valid string', 'input':[{'role':'user','content':'hi'}, {'role':'assistant','content':[{'type':'output_text'}]}, ResponseFunctionToolCall(type='function_call'), {'type':'function_call_output'}]"}}`, + "vLLM validation": `{"error":{"message":"1 validation error: [{'type':'string_type', 'loc':('body','input','str'), 'msg':'Input should be a valid string', 'input':[{'role':'user','content':'hi'}, {'role':'assistant','content':[{'type':'output_text'}]}, ResponseFunctionToolCall(type='function_call'), {'type':'function_call_output'}]}]"}}`, } { t.Run(name, func(t *testing.T) { gin.SetMode(gin.TestMode) @@ -100,6 +101,7 @@ func TestForwardAsChatCompletions_ResponsesSupportedFallsBackWhenStructuredInput func TestConvertedResponsesInputStringRejectionIsNarrow(t *testing.T) { matching := []byte(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string for input, but got an object instead."}}`) require.True(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, matching)) + require.True(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_type","param":"input","message":"Invalid type for 'input': expected a string, but got an array instead."}}`))) for _, tc := range []struct { name string @@ -124,6 +126,159 @@ func TestConvertedResponsesInputStringRejectionIsNarrow(t *testing.T) { } } +func TestConvertedResponsesInputStringRejectionValidationBoundaries(t *testing.T) { + validation := `{'type': 'string_type', 'loc': ('body', 'input', 'str'), 'msg': 'Input should be a valid string', 'input': [{'role': 'user', 'content': 'hello'}]}` + for _, test := range []struct { + name string + message string + want bool + }{ + {name: "complete Pydantic error", message: "1 validation error: [" + validation + "]", want: true}, + {name: "multiple Pydantic errors", message: "2 validation errors for Request: [" + strings.Replace(validation, "'input', 'str'", "'model', 'str'", 1) + ", " + validation + "]", want: true}, + {name: "double quoted keys", message: "1 validation error: [" + strings.ReplaceAll(validation, "'", `"`) + "]", want: true}, + {name: "surrounding whitespace", message: " \n1 validation error: [" + validation + "]\n ", want: true}, + {name: "escaped quote in history", message: `1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string','input':[{'content':'can\'t [close] this }'}]}]`, want: true}, + {name: "bare loc and msg", message: `'loc':('body','input','str'), 'msg':'Input should be a valid string'`}, + {name: "echoed dictionary in unrelated error", message: "Invalid history: " + validation}, + {name: "echoed fake loc and msg in input", message: `1 validation error: [{'type':'string_type','loc':('body','model','str'),'msg':'Input should be a valid string','input':{'loc':('body','input','str'),'msg':'Input should be a valid string'}}]`}, + {name: "echoed fake loc and msg in quoted history", message: `1 validation error: [{'type':'string_type','loc':('body','input',0,'content','str'),'msg':'Input should be a valid string','input':"'loc':('body','input','str'), 'msg':'Input should be a valid string'"}]`}, + {name: "echoed complete error in quoted history", message: `1 validation error: [{'type':'string_type','loc':('body','model','str'),'msg':'Input should be a valid string','input':"1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string'}]"}]`}, + {name: "truncated error list", message: "1 validation error: [" + validation}, + {name: "truncated quoted input", message: `1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string','input':'history}]`}, + {name: "mismatched closing bracket", message: "1 validation error: [" + strings.TrimSuffix(validation, "}") + ")]"}, + {name: "elided validation list", message: "240 validation errors: ... " + validation}, + {name: "incorrect error count", message: "2 validation errors: [" + validation + "]"}, + {name: "overflowed error count", message: "999999999999999999999999 validation errors: [" + validation + "]"}, + {name: "duplicate location", message: "1 validation error: [" + strings.Replace(validation, "'input':", "'loc': ('body', 'model', 'str'), 'input':", 1) + "]"}, + {name: "wrong validation type", message: "1 validation error: [" + strings.Replace(validation, "string_type", "value_error", 1) + "]"}, + {name: "missing validation type", message: "1 validation error: [" + strings.Replace(validation, "'type': 'string_type', ", "", 1) + "]"}, + {name: "location and message in different errors", message: "2 validation errors: [" + strings.Replace(validation, "Input should be a valid string", "Invalid history", 1) + ", " + strings.Replace(validation, "'input', 'str'", "'model', 'str'", 1) + "]"}, + {name: "trailing echoed text", message: "1 validation error: [" + validation + "] echoed history"}, + {name: "structured string array", message: "Expected a string, but got an array instead.", want: true}, + {name: "structured object", message: "Invalid type for 'input': expected a string, but got an object instead.", want: true}, + {name: "structured echoed history", message: "Invalid history containing 'expected a string' and 'got an array'"}, + {name: "structured truncated message", message: "Expected a string, but got an array"}, + } { + t.Run(test.name, func(t *testing.T) { + body, err := json.Marshal(map[string]any{"error": map[string]string{"message": test.message, "code": "invalid_type", "param": "input"}}) + require.NoError(t, err) + require.Equal(t, test.want, isConvertedResponsesInputStringRejection(http.StatusBadRequest, body)) + if test.want { + require.False(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, body[:len(body)-1])) + require.False(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, append(body, []byte(" trailing")...))) + } + }) + } +} + +func TestForwardAsChatCompletions_StructuredInputRetryRequiresCompleteErrorBody(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + limit := int(openAIUpstreamErrorBodyReadLimitForConfig(structuredInputRetryTestConfig())) + for _, test := range []struct { + name string + payload string + readErr error + unknown bool + want bool + }{ + {name: "complete JSON followed by read failure", payload: rejection, readErr: io.ErrUnexpectedEOF}, + {name: "truncated JSON", payload: rejection[:len(rejection)-1]}, + {name: "exact read limit", payload: rejection + strings.Repeat(" ", limit-len(rejection)), want: true}, + {name: "one byte beyond read limit", payload: rejection + strings.Repeat(" ", limit-len(rejection)+1)}, + {name: "valid JSON prefix before discarded suffix", payload: rejection + strings.Repeat(" ", limit-len(rejection)) + "trailing"}, + {name: "unknown capability still retries after read failure", payload: rejection, readErr: io.ErrUnexpectedEOF, unknown: true, want: true}, + {name: "unknown capability still retries beyond read limit", payload: rejection + strings.Repeat(" ", limit-len(rejection)+1), unknown: true, want: true}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}]}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + errorBody := io.NopCloser(strings.NewReader(test.payload)) + if test.readErr != nil { + errorBody = &openAIChatStreamReadErrorCloser{payload: []byte(test.payload), err: test.readErr} + } + account := structuredInputRetryTestAccount() + status := http.StatusBadRequest + if test.unknown { + delete(account.Extra, openai_compat.ExtraKeyResponsesSupported) + status = http.StatusNotFound + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: errorBody}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"chatcmpl_retry","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))}, + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + if test.want { + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requests, 2) + } else { + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.requests, 1) + } + }) + } +} + +type openAIChatFallbackDelayReader struct { + io.Reader + elapsed time.Duration +} + +func (reader *openAIChatFallbackDelayReader) Read(buffer []byte) (int, error) { + if reader.elapsed == 0 { + started := time.Now() + time.Sleep(30 * time.Millisecond) + reader.elapsed = time.Since(started) + } + return reader.Reader.Read(buffer) +} + +func TestForwardAsChatCompletions_RawFallbackPreservesResponsesTiming(t *testing.T) { + for _, unknown := range []bool{false, true} { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("unknown=%t/stream=%t", unknown, stream), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":%t}`, stream)) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + account := structuredInputRetryTestAccount() + status := http.StatusBadRequest + if unknown { + delete(account.Extra, openai_compat.ExtraKeyResponsesSupported) + status = http.StatusNotFound + } + initialBody := &openAIChatFallbackDelayReader{Reader: strings.NewReader(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}`)} + responseBody := `{"id":"chatcmpl_retry","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}` + contentType := "application/json" + if stream { + responseBody = "data: {\"id\":\"chatcmpl_retry\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n" + contentType = "text/event-stream" + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(initialBody)}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(responseBody))}, + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requests, 2) + require.Equal(t, "/v1/responses", upstream.requests[0].URL.Path) + require.Equal(t, "/v1/chat/completions", upstream.requests[1].URL.Path) + require.GreaterOrEqual(t, result.Duration, initialBody.elapsed) + if stream { + require.NotNil(t, result.FirstTokenMs) + require.GreaterOrEqual(t, *result.FirstTokenMs, int(initialBody.elapsed.Milliseconds())) + } + }) + } + } +} + func TestForwardAsChatCompletions_StructuredInputRejectionDoesNotRetryIneligibleRequests(t *testing.T) { rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` for _, test := range []struct { @@ -149,6 +304,8 @@ func TestForwardAsChatCompletions_StructuredInputRejectionDoesNotRetryIneligible {name: "rate limit", status: http.StatusTooManyRequests}, {name: "unrelated validation", rejection: `{"error":{"code":"invalid_type","param":"tools","message":"Expected a string, but got an array instead."}}`}, {name: "nested validation", rejection: `{"error":{"message":"'loc':('body','input',0,'content','str'), 'msg':'Input should be a valid string'"}}`}, + {name: "echoed validation", rejection: `{"error":{"message":"1 validation error: [{'type':'string_type','loc':('body','model','str'),'msg':'Input should be a valid string','input':{'loc':('body','input','str'),'msg':'Input should be a valid string'}}]"}}`}, + {name: "echoed structured error", rejection: `{"error":{"code":"invalid_type","param":"input","message":"Invalid history containing expected a string but got an array"}}`}, } { t.Run(test.name, func(t *testing.T) { body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) @@ -1608,7 +1765,7 @@ func TestGPT61SolOnlyChatFallbackRejectsToolsAndDisabledReasoning(t *testing.T) svc := &OpenAIGatewayService{cfg: &config.Config{}} var err error if responses { - _, err = svc.forwardResponsesViaRawChatCompletions(context.Background(), c, account, []byte(body)) + _, err = svc.forwardResponsesViaRawChatCompletions(context.Background(), c, account, []byte(body), time.Now()) } else { _, err = svc.forwardAnthropicViaRawChatCompletions(context.Background(), c, account, []byte(body), "") } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 10c525550a..9cd9db4e26 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -167,7 +167,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco case APIProtocolResponses: break default: - return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) + return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body, startTime) } } @@ -196,7 +196,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { - return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) + return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body, startTime) } SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) { diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index fabbcbc156..184e3152dc 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -29,9 +29,8 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( c *gin.Context, account *Account, body []byte, + startTime time.Time, ) (*OpenAIForwardResult, error) { - startTime := time.Now() - var responsesReq apicompat.ResponsesRequest if err := json.Unmarshal(body, &responsesReq); err != nil { writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go index 989421bf8b..0cc999c57d 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go @@ -5,6 +5,7 @@ package service import ( "bytes" "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -19,6 +20,34 @@ import ( "github.com/tidwall/gjson" ) +func TestForwardResponses_ChatFallbackPreservesProvidedStartTime(t *testing.T) { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"gpt-5.4","input":"hello","stream":%t}`, stream)) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + responseBody := `{"id":"chatcmpl_timing","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}` + contentType := "application/json" + if stream { + responseBody = "data: {\"id\":\"chatcmpl_timing\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n" + contentType = "text/event-stream" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(responseBody))}} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + startTime := time.Now().Add(-time.Minute) + result, err := svc.forwardResponsesViaRawChatCompletions(context.Background(), c, forceChatResponsesFallbackAccount(), body, startTime) + require.NoError(t, err) + require.NotNil(t, result) + require.GreaterOrEqual(t, result.Duration, time.Minute) + if stream { + require.NotNil(t, result.FirstTokenMs) + require.GreaterOrEqual(t, *result.FirstTokenMs, int(time.Minute.Milliseconds())) + } + }) + } +} + func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletions(t *testing.T) { gin.SetMode(gin.TestMode) From f7f1112cdab09e9bee982d77ae253d5d20a56e73 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 6 Oct 2026 10:49:15 +0800 Subject: [PATCH 3/5] fix(deps): update Vue and source map security patches --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 238 ++++++++++++++++++++++------------------ 2 files changed, 134 insertions(+), 106 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 19d2303c72..067fcca06d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ "marked": "^17.0.1", "pinia": "^2.1.7", "qrcode": "^1.5.4", - "vue": "^3.4.0", + "vue": "^3.5.42", "vue-chartjs": "^5.3.0", "vue-draggable-plus": "^0.6.1", "vue-i18n": "^9.14.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8df2e3e153..b5f7b55d1e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -25,10 +25,10 @@ importers: version: 9.0.1 '@tanstack/vue-virtual': specifier: ^3.13.23 - version: 3.13.23(vue@3.5.26(typescript@5.6.3)) + version: 3.13.23(vue@3.5.42(typescript@5.6.3)) '@vueuse/core': specifier: ^10.7.0 - version: 10.11.1(vue@3.5.26(typescript@5.6.3)) + version: 10.11.1(vue@3.5.42(typescript@5.6.3)) axios: specifier: ^1.20.0 version: 1.20.0 @@ -49,25 +49,25 @@ importers: version: 17.0.1 pinia: specifier: ^2.1.7 - version: 2.3.1(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3)) + version: 2.3.1(typescript@5.6.3)(vue@3.5.42(typescript@5.6.3)) qrcode: specifier: ^1.5.4 version: 1.5.4 vue: - specifier: ^3.4.0 - version: 3.5.26(typescript@5.6.3) + specifier: ^3.5.42 + version: 3.5.42(typescript@5.6.3) vue-chartjs: specifier: ^5.3.0 - version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3)) + version: 5.3.3(chart.js@4.5.1)(vue@3.5.42(typescript@5.6.3)) vue-draggable-plus: specifier: ^0.6.1 version: 0.6.1(@types/sortablejs@1.15.9) vue-i18n: specifier: ^9.14.5 - version: 9.14.5(vue@3.5.26(typescript@5.6.3)) + version: 9.14.5(vue@3.5.42(typescript@5.6.3)) vue-router: specifier: ^4.2.5 - version: 4.6.4(vue@3.5.26(typescript@5.6.3)) + version: 4.6.4(vue@3.5.42(typescript@5.6.3)) xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -98,7 +98,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.6.3) '@vitejs/plugin-vue': specifier: ^5.2.3 - version: 5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.26(typescript@5.6.3)) + version: 5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.42(typescript@5.6.3)) '@vitest/coverage-v8': specifier: ^2.1.9 version: 2.1.9(vitest@2.1.9(@types/node@20.19.27)(jsdom@24.1.3)) @@ -216,15 +216,28 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.28.5': resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -245,6 +258,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@base-ui/react@1.3.0': resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==} engines: {node: '>=14.0.0'} @@ -1689,17 +1706,17 @@ packages: '@volar/typescript@2.4.15': resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} - '@vue/compiler-core@3.5.26': - resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} - '@vue/compiler-dom@3.5.26': - resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} - '@vue/compiler-sfc@3.5.26': - resolution: {integrity: sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==} + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} - '@vue/compiler-ssr@3.5.26': - resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==} + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} '@vue/compiler-vue2@2.7.16': resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} @@ -1715,22 +1732,20 @@ packages: typescript: optional: true - '@vue/reactivity@3.5.26': - resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==} + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} - '@vue/runtime-core@3.5.26': - resolution: {integrity: sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==} + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} - '@vue/runtime-dom@3.5.26': - resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==} + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} - '@vue/server-renderer@3.5.26': - resolution: {integrity: sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==} - peerDependencies: - vue: 3.5.26 + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} - '@vue/shared@3.5.26': - resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} '@vue/test-utils@2.4.6': resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} @@ -2338,8 +2353,8 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - entities@7.0.0: - resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} error-ex@1.3.4: @@ -3928,8 +3943,8 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map@0.5.7: @@ -4390,8 +4405,8 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue@3.5.26: - resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -4620,12 +4635,20 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/parser@7.28.5': dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/runtime@7.28.4': {} '@babel/runtime@7.29.2': {} @@ -4653,6 +4676,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@base-ui/react@1.3.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.29.2 @@ -4988,7 +5016,7 @@ snapshots: '@intlify/message-compiler@9.14.5': dependencies: '@intlify/shared': 9.14.5 - source-map-js: 1.2.1 + source-map-js: 1.2.2 '@intlify/shared@9.14.5': {} @@ -5044,7 +5072,7 @@ snapshots: '@lobehub/icons@4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@lobehub/ui': 4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.26(typescript@5.6.3)) + '@lobehub/ui': 4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.42(typescript@5.6.3)) antd: 6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) antd-style: 4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) lucide-react: 0.469.0(react@19.2.3) @@ -5055,7 +5083,7 @@ snapshots: - '@types/react' - supports-color - '@lobehub/ui@4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.26(typescript@5.6.3))': + '@lobehub/ui@4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.42(typescript@5.6.3))': dependencies: '@ant-design/cssinjs': 2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@base-ui/react': 1.3.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -5120,7 +5148,7 @@ snapshots: remark-github: 12.0.0 remark-math: 6.0.0 shiki: 3.23.0 - shiki-stream: 0.1.4(react@19.2.3)(vue@3.5.26(typescript@5.6.3)) + shiki-stream: 0.1.4(react@19.2.3)(vue@3.5.42(typescript@5.6.3)) swr: 2.4.1(react@19.2.3) ts-md5: 2.0.1 unified: 11.0.5 @@ -5872,10 +5900,10 @@ snapshots: '@tanstack/virtual-core@3.13.23': {} - '@tanstack/vue-virtual@3.13.23(vue@3.5.26(typescript@5.6.3))': + '@tanstack/vue-virtual@3.13.23(vue@3.5.42(typescript@5.6.3))': dependencies: '@tanstack/virtual-core': 3.13.23 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.42(typescript@5.6.3) '@types/d3-array@3.2.2': {} @@ -6147,10 +6175,10 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.3 - '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.26(typescript@5.6.3))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.42(typescript@5.6.3))': dependencies: vite: 5.4.21(@types/node@20.19.27) - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.42(typescript@5.6.3) '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@20.19.27)(jsdom@24.1.3))': dependencies: @@ -6222,35 +6250,35 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 - '@vue/compiler-core@3.5.26': + '@vue/compiler-core@3.5.42': dependencies: - '@babel/parser': 7.28.5 - '@vue/shared': 3.5.26 - entities: 7.0.0 + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.42 + entities: 7.0.1 estree-walker: 2.0.2 - source-map-js: 1.2.1 + source-map-js: 1.2.2 - '@vue/compiler-dom@3.5.26': + '@vue/compiler-dom@3.5.42': dependencies: - '@vue/compiler-core': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 - '@vue/compiler-sfc@3.5.26': + '@vue/compiler-sfc@3.5.42': dependencies: - '@babel/parser': 7.28.5 - '@vue/compiler-core': 3.5.26 - '@vue/compiler-dom': 3.5.26 - '@vue/compiler-ssr': 3.5.26 - '@vue/shared': 3.5.26 + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 estree-walker: 2.0.2 magic-string: 0.30.21 postcss: 8.5.23 - source-map-js: 1.2.1 + source-map-js: 1.2.2 - '@vue/compiler-ssr@3.5.26': + '@vue/compiler-ssr@3.5.42': dependencies: - '@vue/compiler-dom': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 '@vue/compiler-vue2@2.7.16': dependencies: @@ -6262,9 +6290,9 @@ snapshots: '@vue/language-core@2.2.12(typescript@5.6.3)': dependencies: '@volar/language-core': 2.4.15 - '@vue/compiler-dom': 3.5.26 + '@vue/compiler-dom': 3.5.42 '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.26 + '@vue/shared': 3.5.42 alien-signals: 1.0.13 minimatch: 9.0.5 muggle-string: 0.4.1 @@ -6272,50 +6300,50 @@ snapshots: optionalDependencies: typescript: 5.6.3 - '@vue/reactivity@3.5.26': + '@vue/reactivity@3.5.42': dependencies: - '@vue/shared': 3.5.26 + '@vue/shared': 3.5.42 - '@vue/runtime-core@3.5.26': + '@vue/runtime-core@3.5.42': dependencies: - '@vue/reactivity': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 - '@vue/runtime-dom@3.5.26': + '@vue/runtime-dom@3.5.42': dependencies: - '@vue/reactivity': 3.5.26 - '@vue/runtime-core': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 csstype: 3.2.3 - '@vue/server-renderer@3.5.26(vue@3.5.26(typescript@5.6.3))': + '@vue/server-renderer@3.5.42': dependencies: - '@vue/compiler-ssr': 3.5.26 - '@vue/shared': 3.5.26 - vue: 3.5.26(typescript@5.6.3) + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 - '@vue/shared@3.5.26': {} + '@vue/shared@3.5.42': {} '@vue/test-utils@2.4.6': dependencies: js-beautify: 1.15.4 vue-component-type-helpers: 2.2.12 - '@vueuse/core@10.11.1(vue@3.5.26(typescript@5.6.3))': + '@vueuse/core@10.11.1(vue@3.5.42(typescript@5.6.3))': dependencies: '@types/web-bluetooth': 0.0.20 '@vueuse/metadata': 10.11.1 - '@vueuse/shared': 10.11.1(vue@3.5.26(typescript@5.6.3)) - vue-demi: 0.14.10(vue@3.5.26(typescript@5.6.3)) + '@vueuse/shared': 10.11.1(vue@3.5.42(typescript@5.6.3)) + vue-demi: 0.14.10(vue@3.5.42(typescript@5.6.3)) transitivePeerDependencies: - '@vue/composition-api' - vue '@vueuse/metadata@10.11.1': {} - '@vueuse/shared@10.11.1(vue@3.5.26(typescript@5.6.3))': + '@vueuse/shared@10.11.1(vue@3.5.42(typescript@5.6.3))': dependencies: - vue-demi: 0.14.10(vue@3.5.26(typescript@5.6.3)) + vue-demi: 0.14.10(vue@3.5.42(typescript@5.6.3)) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -6967,7 +6995,7 @@ snapshots: entities@6.0.1: {} - entities@7.0.0: {} + entities@7.0.1: {} error-ex@1.3.4: dependencies: @@ -7812,7 +7840,7 @@ snapshots: dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 - source-map-js: 1.2.1 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -8532,11 +8560,11 @@ snapshots: pify@2.3.0: {} - pinia@2.3.1(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3)): + pinia@2.3.1(typescript@5.6.3)(vue@3.5.42(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.26(typescript@5.6.3) - vue-demi: 0.14.10(vue@3.5.26(typescript@5.6.3)) + vue: 3.5.42(typescript@5.6.3) + vue-demi: 0.14.10(vue@3.5.42(typescript@5.6.3)) optionalDependencies: typescript: 5.6.3 transitivePeerDependencies: @@ -8598,7 +8626,7 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 prelude-ls@1.2.1: {} @@ -9080,12 +9108,12 @@ snapshots: shebang-regex@3.0.0: {} - shiki-stream@0.1.4(react@19.2.3)(vue@3.5.26(typescript@5.6.3)): + shiki-stream@0.1.4(react@19.2.3)(vue@3.5.42(typescript@5.6.3)): dependencies: '@shikijs/core': 3.23.0 optionalDependencies: react: 19.2.3 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.42(typescript@5.6.3) shiki@3.23.0: dependencies: @@ -9104,7 +9132,7 @@ snapshots: slash@3.0.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map@0.5.7: {} @@ -9504,16 +9532,16 @@ snapshots: vscode-uri@3.1.0: {} - vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3)): + vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.42(typescript@5.6.3)): dependencies: chart.js: 4.5.1 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.42(typescript@5.6.3) vue-component-type-helpers@2.2.12: {} - vue-demi@0.14.10(vue@3.5.26(typescript@5.6.3)): + vue-demi@0.14.10(vue@3.5.42(typescript@5.6.3)): dependencies: - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.42(typescript@5.6.3) vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9): dependencies: @@ -9532,17 +9560,17 @@ snapshots: transitivePeerDependencies: - supports-color - vue-i18n@9.14.5(vue@3.5.26(typescript@5.6.3)): + vue-i18n@9.14.5(vue@3.5.42(typescript@5.6.3)): dependencies: '@intlify/core-base': 9.14.5 '@intlify/shared': 9.14.5 '@vue/devtools-api': 6.6.4 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.42(typescript@5.6.3) - vue-router@4.6.4(vue@3.5.26(typescript@5.6.3)): + vue-router@4.6.4(vue@3.5.42(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.42(typescript@5.6.3) vue-tsc@2.2.12(typescript@5.6.3): dependencies: @@ -9550,13 +9578,13 @@ snapshots: '@vue/language-core': 2.2.12(typescript@5.6.3) typescript: 5.6.3 - vue@3.5.26(typescript@5.6.3): + vue@3.5.42(typescript@5.6.3): dependencies: - '@vue/compiler-dom': 3.5.26 - '@vue/compiler-sfc': 3.5.26 - '@vue/runtime-dom': 3.5.26 - '@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@5.6.3)) - '@vue/shared': 3.5.26 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 optionalDependencies: typescript: 5.6.3 From 63800c50d462efc6436e96b9dd46ae8f98a054bc Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 7 Oct 2026 08:50:13 +0800 Subject: [PATCH 4/5] fix(gateway): guard structured-input fallback against unsafe retries --- .../openai_gateway_chat_completions.go | 37 ++++++++ .../openai_gateway_chat_completions_test.go | 87 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index efa048199a..8f177f6dcf 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -438,6 +438,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( openai_compat.NormalizeResponsesSupportMode(responsesMode) == openai_compat.ResponsesSupportModeAuto && openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportYes && !isResponsesShape && + (ctx == nil || ctx.Err() == nil) && + (c.Request == nil || c.Request.Context().Err() == nil) && + !IsResponseCommitted(c) && + !c.Writer.Written() && completeErrorBody && isConvertedResponsesInputStringRejection(resp.StatusCode, respBody) { logger.L().Info("openai chat_completions: structured Responses input rejected, falling back to raw chat completions", @@ -597,6 +601,39 @@ func isConvertedResponsesInputStringRejection(status int, responseBody []byte) b if status != http.StatusBadRequest || !json.Valid(responseBody) { return false } + envelope := gjson.ParseBytes(responseBody) + for _, object := range []gjson.Result{envelope, envelope.Get("error")} { + if !object.IsObject() { + return false + } + keys := make(map[string]bool) + unambiguous := true + object.ForEach(func(key, value gjson.Result) bool { + name := key.String() + if keys[name] { + unambiguous = false + return false + } + keys[name] = true + switch name { + case "usage", "output", "output_text", "choices", "tool_calls", "response", "status", "incomplete_details": + unambiguous = false + case "code": + code := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = code == "" || code == "invalid_type" || code == "invalid_request_error" + case "param": + param := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = param == "" || param == "input" + case "type": + errorType := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = errorType == "" || errorType == "invalid_type" || errorType == "invalid_request_error" + } + return unambiguous + }) + if !unambiguous { + return false + } + } message := gjson.GetBytes(responseBody, "error.message").String() if isResponsesInputStringValidationMessage(message) { return true diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 198edc0645..5865f9d765 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -98,6 +98,93 @@ func TestForwardAsChatCompletions_ResponsesSupportedFallsBackWhenStructuredInput } } +type openAIChatFallbackBoundaryReader struct { + io.Reader + beforeRead func() +} + +func (reader *openAIChatFallbackBoundaryReader) Read(buffer []byte) (int, error) { + if reader.beforeRead != nil { + reader.beforeRead() + reader.beforeRead = nil + } + return reader.Reader.Read(buffer) +} + +func TestForwardAsChatCompletions_StructuredInputRetrySafetyBoundaries(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + validation := `"message":"1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string'}]"` + for _, test := range []struct { + name string + body string + }{ + {name: "duplicate error", body: strings.TrimSuffix(rejection, "}") + `,"error":{"code":"rate_limit_exceeded"}}`}, + {name: "duplicate code", body: strings.Replace(rejection, `"code":"invalid_type"`, `"code":"invalid_type","code":"rate_limit_exceeded"`, 1)}, + {name: "duplicate escaped param", body: strings.Replace(rejection, `"param":"input"`, `"param":"input","p\u0061ram":"tools"`, 1)}, + {name: "duplicate message", body: strings.Replace(rejection, `"message":"Expected a string, but got an array instead."`, `"message":"Expected a string, but got an array instead.","message":"Input is too long."`, 1)}, + {name: "contradictory error type", body: strings.Replace(rejection, `"error":{`, `"error":{"type":"authentication_error",`, 1)}, + {name: "contradictory envelope type", body: strings.TrimSuffix(rejection, "}") + `,"type":"rate_limit_error"}`}, + {name: "validation contradictory type", body: `{"error":{"type":"rate_limit_error",` + validation + `}}`}, + {name: "validation contradictory envelope type", body: `{"type":"authentication_error","error":{` + validation + `}}`}, + {name: "validation contradictory code", body: `{"error":{"code":"rate_limit_exceeded",` + validation + `}}`}, + {name: "validation contradictory param", body: `{"error":{"param":"tools",` + validation + `}}`}, + {name: "contradictory envelope code", body: strings.TrimSuffix(rejection, "}") + `,"code":"rate_limit_exceeded"}`}, + {name: "contradictory envelope param", body: strings.TrimSuffix(rejection, "}") + `,"param":"tools"}`}, + {name: "type cannot override code", body: strings.Replace(rejection, `"code":"invalid_type"`, `"code":"rate_limit_exceeded","type":"invalid_type"`, 1)}, + {name: "usage", body: strings.TrimSuffix(rejection, "}") + `,"usage":{"input_tokens":12,"output_tokens":3}}`}, + {name: "error usage", body: strings.Replace(rejection, `"error":{`, `"error":{"usage":{"input_tokens":12},`, 1)}, + {name: "output", body: strings.TrimSuffix(rejection, "}") + `,"output":[{"type":"message","content":[{"type":"output_text","text":"partial"}]}]}`}, + {name: "output text", body: strings.TrimSuffix(rejection, "}") + `,"output_text":"partial"}`}, + {name: "tool calls", body: strings.TrimSuffix(rejection, "}") + `,"tool_calls":[{"id":"call_partial","type":"function","function":{"name":"lookup","arguments":"{}"}}]}`}, + {name: "choices", body: strings.TrimSuffix(rejection, "}") + `,"choices":[{"delta":{"content":"partial"}}]}`}, + {name: "nested response", body: strings.TrimSuffix(rejection, "}") + `,"response":{"status":"incomplete","usage":{"input_tokens":12},"output":[]}}`}, + {name: "partial status", body: strings.TrimSuffix(rejection, "}") + `,"status":"in_progress"}`}, + {name: "incomplete details", body: strings.TrimSuffix(rejection, "}") + `,"incomplete_details":{"reason":"max_output_tokens"}}`}, + {name: "partial event", body: strings.TrimSuffix(rejection, "}") + `,"type":"response.output_text.delta","delta":"partial"}`}, + {name: "caller canceled"}, + {name: "request canceled"}, + {name: "response committed"}, + {name: "writer committed"}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + requestCtx, cancelRequest := context.WithCancel(context.Background()) + defer cancelRequest() + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)).WithContext(requestCtx) + errorBody := test.body + if errorBody == "" { + errorBody = rejection + } + reader := &openAIChatFallbackBoundaryReader{Reader: strings.NewReader(errorBody), beforeRead: func() { + switch test.name { + case "caller canceled": + cancel() + case "request canceled": + cancelRequest() + case "response committed": + MarkResponseCommitted(c) + case "writer committed": + c.Writer.WriteHeaderNow() + } + }} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(reader), + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + result, err := svc.ForwardAsChatCompletions(ctx, c, structuredInputRetryTestAccount(), body, "", "") + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.requests, 1) + require.Equal(t, "/v1/responses", upstream.requests[0].URL.Path) + }) + } +} + func TestConvertedResponsesInputStringRejectionIsNarrow(t *testing.T) { matching := []byte(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string for input, but got an object instead."}}`) require.True(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, matching)) From daa79157491f25939b502f5c040eb11768a46e30 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 7 Oct 2026 08:50:14 +0800 Subject: [PATCH 5/5] fix(deps): update SheetJS past expired security exceptions --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 73 ++++------------------------------------- 2 files changed, 7 insertions(+), 68 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 067fcca06d..d15078ce6b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,7 @@ "vue-draggable-plus": "^0.6.1", "vue-i18n": "^9.14.5", "vue-router": "^4.2.5", - "xlsx": "^0.18.5" + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { "@intlify/message-compiler": "9.14.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b5f7b55d1e..471eeeb22c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -69,8 +69,8 @@ importers: specifier: ^4.2.5 version: 4.6.4(vue@3.5.42(typescript@5.6.3)) xlsx: - specifier: ^0.18.5 - version: 0.18.5 + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz devDependencies: '@intlify/message-compiler': specifier: 9.14.5 @@ -1778,10 +1778,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adler-32@1.3.1: - resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} - engines: {node: '>=0.8'} - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1938,10 +1934,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - cfb@1.2.2: - resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} - engines: {node: '>=0.8'} - chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -2003,10 +1995,6 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - codepage@1.15.0: - resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} - engines: {node: '>=0.8'} - collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -2068,11 +2056,6 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} - engines: {node: '>=0.8'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2567,10 +2550,6 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} - frac@1.1.2: - resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} - engines: {node: '>=0.8'} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -3966,10 +3945,6 @@ packages: resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} engines: {node: '>=0.10.0'} - ssf@0.11.2: - resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} - engines: {node: '>=0.8'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4450,18 +4425,10 @@ packages: engines: {node: '>=8'} hasBin: true - wmf@1.0.2: - resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} - engines: {node: '>=0.8'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - word@0.3.0: - resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} - engines: {node: '>=0.8'} - wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -4489,8 +4456,9 @@ packages: utf-8-validate: optional: true - xlsx@0.18.5: - resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 engines: {node: '>=0.8'} hasBin: true @@ -6362,8 +6330,6 @@ snapshots: acorn@8.16.0: {} - adler-32@1.3.1: {} - agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -6576,11 +6542,6 @@ snapshots: ccount@2.0.1: {} - cfb@1.2.2: - dependencies: - adler-32: 1.3.1 - crc-32: 1.2.2 - chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -6653,8 +6614,6 @@ snapshots: clsx@2.1.1: {} - codepage@1.15.0: {} - collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -6708,8 +6667,6 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - crc-32@1.2.2: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7271,8 +7228,6 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - frac@1.1.2: {} - fraction.js@5.3.4: {} framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -9146,10 +9101,6 @@ snapshots: dependencies: extend-shallow: 3.0.2 - ssf@0.11.2: - dependencies: - frac: 1.1.2 - stackback@0.0.2: {} std-env@3.10.0: {} @@ -9618,12 +9569,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - wmf@1.0.2: {} - word-wrap@1.2.5: {} - word@0.3.0: {} - wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -9646,15 +9593,7 @@ snapshots: ws@8.19.0: {} - xlsx@0.18.5: - dependencies: - adler-32: 1.3.1 - cfb: 1.2.2 - codepage: 1.15.0 - crc-32: 1.2.2 - ssf: 0.11.2 - wmf: 1.0.2 - word: 0.3.0 + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} xml-name-validator@4.0.0: {}