diff --git a/AGENTS.md b/AGENTS.md index 86e509ba..81fb70bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,7 @@ Run the private upstream component check before any of the following work: Required procedure: -1. Read `.agents/upstream-sync/components.json`, `.agents/upstream-sync/new-api-sync.md`, and the living implementation plan at `new-api/.tmp/MAX_API_UPSTREAM_IMPLEMENTATION_PLAN.md`. +1. Read `.agents/upstream-sync/components.json`, `.agents/upstream-sync/new-api-sync.md`, and the living implementation plan at `.tmp/MAX_API_UPSTREAM_IMPLEMENTATION_PLAN.md`. 2. Run the checker from the repository root with the cross-platform `pwsh` executable when available: `pwsh -NoProfile -ExecutionPolicy Bypass -File .agents/upstream-sync/check-new-api.ps1`. On Windows hosts without `pwsh`, use the equivalent Windows PowerShell fallback: @@ -179,4 +179,4 @@ RelayKit-specific requirements: - Review changes to converter identity, quality, steps, usage, and diagnostics as compatibility-sensitive interface changes. - RelayKit does not own billing. Preserve MAX-API's billing, reservation, settlement, retry, and logging invariants, and prove that normalized usage is applied exactly once. - If the private checker or registry is unavailable in a fresh workspace, perform the equivalent remote commit/path comparison manually and record the missing maintenance prerequisite instead of silently skipping the check. -- If the living plan is missing in a fresh workspace, recreate it under `new-api/.tmp/` from the current code and upstream records before continuing; never substitute an old chat summary for the plan. +- If the living plan is missing in a fresh workspace, recreate `.tmp/MAX_API_UPSTREAM_IMPLEMENTATION_PLAN.md` from the current code and upstream records before continuing; never substitute an old chat summary for the plan. diff --git a/controller/relay.go b/controller/relay.go index 039b1ec1..e631d474 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -166,6 +166,9 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { if maxAPIError != nil { logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(maxAPIError.Error()))) maxAPIError.SetMessage(common.MessageWithRequestId(maxAPIError.Error(), requestId)) + if writeStartedStreamError(c, relayFormat, maxAPIError) { + return + } switch relayFormat { case types.RelayFormatOpenAIRealtime: helper.WssError(c, ws, maxAPIError.ToOpenAIError()) diff --git a/controller/relay_stream_error.go b/controller/relay_stream_error.go new file mode 100644 index 00000000..d127bd77 --- /dev/null +++ b/controller/relay_stream_error.go @@ -0,0 +1,27 @@ +package controller + +import ( + "strings" + + "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" +) + +// Once SSE is committed, a JSON response cannot change the HTTP status and +// would corrupt event framing. The controller remains the single error writer. +func writeStartedStreamError(c *gin.Context, format types.RelayFormat, apiErr *types.MaxAPIError) bool { + if format != types.RelayFormatOpenAI || !strings.HasPrefix(c.Writer.Header().Get("Content-Type"), "text/event-stream") { + return false + } + if !c.Writer.Written() { + // The scanner prepares SSE headers before receiving any output. Let + // Gin's ordinary error responder select application/json if no bytes + // were committed; otherwise clients would parse raw JSON as SSE. + c.Writer.Header().Del("Content-Type") + return false + } + _ = helper.ObjectData(c, gin.H{"error": apiErr.ToOpenAIError()}) + helper.Done(c) + return true +} diff --git a/controller/relay_stream_error_test.go b/controller/relay_stream_error_test.go new file mode 100644 index 00000000..7970205c --- /dev/null +++ b/controller/relay_stream_error_test.go @@ -0,0 +1,184 @@ +package controller + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/relay/channel/openai" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/setting/operation_setting" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type partialNativeStreamWriter struct { + *httptest.ResponseRecorder + short bool + writes int +} + +func (w *partialNativeStreamWriter) Write(data []byte) (int, error) { + w.writes++ + if strings.Contains(string(data), "broken") { + n, _ := w.ResponseRecorder.Write(data[:len(data)/2]) + if w.short { + return n, nil + } + return n, io.ErrClosedPipe + } + return w.ResponseRecorder.Write(data) +} + +func TestNativeChatWriteFailureStaysLatchedThroughController(t *testing.T) { + oldFlag, oldTimes := common.EmptyCompletionRetryEnabled, common.RetryTimes + common.EmptyCompletionRetryEnabled, common.RetryTimes = true, 2 + t.Cleanup(func() { common.EmptyCompletionRetryEnabled, common.RetryTimes = oldFlag, oldTimes }) + for _, partial := range []bool{false, true} { + for _, short := range []bool{false, true} { + t.Run(fmt.Sprintf("partial=%t/short=%t", partial, short), func(t *testing.T) { + writer := &partialNativeStreamWriter{ResponseRecorder: httptest.NewRecorder(), short: short} + c, _ := gin.CreateTestContext(writer) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + info := &relaycommon.RelayInfo{RelayMode: relayconstant.RelayModeChatCompletions, RelayFormat: types.RelayFormatOpenAI, DisablePing: true, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-test"}} + info.SetEstimatePromptTokens(38) + frames := "" + if partial { + frames = "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + } + frames += "data: {\"choices\":[{\"delta\":{\"content\":\"broken\"}}],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":999,\"total_tokens\":1999}}\n\ndata: [DONE]\n\n" + usage, apiErr := openai.OaiStreamHandler(c, info, &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(frames))}) + require.NotNil(t, apiErr) + require.True(t, types.IsSkipRetryError(apiErr)) + require.False(t, shouldRetry(c, apiErr, 1)) + if partial { + require.Equal(t, service.ResponseText2Usage(c, "hello", info.UpstreamModelName, 38), usage) + } else { + require.Nil(t, usage) + } + body, writes := writer.Body.String(), writer.writes + require.NotEmpty(t, body) + require.True(t, writeStartedStreamError(c, types.RelayFormatOpenAI, apiErr)) + require.Equal(t, writes, writer.writes, "controller must not append to a partially written SSE frame") + require.Equal(t, body, writer.Body.String()) + n, err := c.Writer.WriteString("late write") + require.Zero(t, n) + if short { + require.ErrorIs(t, err, io.ErrShortWrite) + } else { + require.ErrorIs(t, err, io.ErrClosedPipe) + } + require.Equal(t, writes, writer.writes) + }) + } + } +} + +func TestNativeChatErrorReachesControllerRetryPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + oldFlag, oldTimes, oldRanges := common.EmptyCompletionRetryEnabled, common.RetryTimes, operation_setting.AutomaticRetryStatusCodeRanges + common.EmptyCompletionRetryEnabled, common.RetryTimes = true, 2 + operation_setting.AutomaticRetryStatusCodeRanges = []operation_setting.StatusCodeRange{{Start: 502, End: 502}} + t.Cleanup(func() { + common.EmptyCompletionRetryEnabled, common.RetryTimes, operation_setting.AutomaticRetryStatusCodeRanges = oldFlag, oldTimes, oldRanges + }) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + info := &relaycommon.RelayInfo{RelayMode: relayconstant.RelayModeChatCompletions, RelayFormat: types.RelayFormatOpenAI, DisablePing: true, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-test"}} + _, apiErr := openai.OaiStreamHandler(c, info, &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\ndata: {\"error\":{\"message\":\"overloaded\",\"type\":\"upstream_error\"}}\n\n"))}) + require.NotNil(t, apiErr) + require.True(t, shouldRetry(c, apiErr, 1)) + require.False(t, shouldRetry(c, apiErr, 0)) + operation_setting.AutomaticRetryStatusCodeRanges = nil + require.False(t, shouldRetry(c, apiErr, 1)) + operation_setting.AutomaticRetryStatusCodeRanges = []operation_setting.StatusCodeRange{{Start: 502, End: 502}} + c.Set("specific_channel_id", 1) + require.False(t, shouldRetry(c, apiErr, 1)) +} + +func TestStartedChatStreamErrorUsesSSEFraming(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + apiErr := types.NewOpenAIError(errors.New("overloaded"), types.ErrorCodeBadResponse, 502) + require.False(t, writeStartedStreamError(c, types.RelayFormatOpenAI, apiErr)) + helper.SetEventStreamHeaders(c) + require.NoError(t, helper.StringData(c, `{"choices":[{"delta":{"content":"hello"}}]}`)) + require.True(t, writeStartedStreamError(c, types.RelayFormatOpenAI, apiErr)) + require.Equal(t, http.StatusOK, recorder.Code) + frames := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n") + require.Len(t, frames, 3) + require.True(t, strings.HasPrefix(frames[1], "data: ")) + require.Contains(t, frames[1], "overloaded") + require.Equal(t, "data: [DONE]", frames[2]) +} + +func TestNativeChatRequestErrorsRespectControllerRetryPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + oldFlag, oldTimes, oldRanges := common.EmptyCompletionRetryEnabled, common.RetryTimes, operation_setting.AutomaticRetryStatusCodeRanges + common.EmptyCompletionRetryEnabled, common.RetryTimes = true, 2 + operation_setting.AutomaticRetryStatusCodeRanges = []operation_setting.StatusCodeRange{{Start: 502, End: 502}} + t.Cleanup(func() { + common.EmptyCompletionRetryEnabled, common.RetryTimes, operation_setting.AutomaticRetryStatusCodeRanges = oldFlag, oldTimes, oldRanges + }) + for _, tc := range []struct { + name string + errorType string + code any + retry bool + }{ + {"invalid request without code", "invalid_request_error", nil, false}, + {"context limit", "upstream_error", "context_length_exceeded", false}, + {"invalid prompt", "upstream_error", "invalid_prompt", false}, + {"content policy", "invalid_request_error", "content_policy_violation", false}, + {"overload", "upstream_error", nil, true}, + {"server error", "server_error", "server_error", true}, + {"rate limit", "invalid_request_error", "rate_limit_exceeded", true}, + {"provider quota", "invalid_request_error", "insufficient_quota", true}, + {"provider credential", "invalid_request_error", "invalid_api_key", true}, + {"provider model", "invalid_request_error", "model_not_found", true}, + } { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + upstream := types.OpenAIError{Message: "synthetic request failure", Type: tc.errorType, Code: tc.code} + body, err := common.Marshal(map[string]any{"error": upstream}) + require.NoError(t, err) + info := &relaycommon.RelayInfo{RelayMode: relayconstant.RelayModeChatCompletions, RelayFormat: types.RelayFormatOpenAI, DisablePing: true, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-test"}} + usage, apiErr := openai.OaiStreamHandler(c, info, &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\ndata: " + string(body) + "\n\n"))}) + require.NotNil(t, apiErr) + require.Nil(t, usage) + require.False(t, c.Writer.Written()) + require.Equal(t, upstream.Message, apiErr.ToOpenAIError().Message) + require.Equal(t, upstream.Type, apiErr.ToOpenAIError().Type) + require.Equal(t, upstream.Code, apiErr.ToOpenAIError().Code) + require.Equal(t, tc.retry, shouldRetry(c, apiErr, 1)) + require.False(t, shouldRetry(c, apiErr, 0)) + }) + } +} + +func TestNativeChatUnstartedErrorUsesJSONStatusAndContentType(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + apiErr := types.NewOpenAIError(errors.New("overloaded"), types.ErrorCodeBadResponse, http.StatusBadGateway) + helper.SetEventStreamHeaders(c) + require.False(t, writeStartedStreamError(c, types.RelayFormatOpenAI, apiErr)) + c.JSON(apiErr.StatusCode, gin.H{"error": apiErr.ToOpenAIError()}) + require.Equal(t, http.StatusBadGateway, recorder.Code) + require.Contains(t, recorder.Header().Get("Content-Type"), "application/json") + require.Contains(t, recorder.Body.String(), "overloaded") + require.NotContains(t, recorder.Body.String(), "data:") +} diff --git a/pkg/billingexpr/timezone_string_test.go b/pkg/billingexpr/timezone_string_test.go new file mode 100644 index 00000000..9551924e --- /dev/null +++ b/pkg/billingexpr/timezone_string_test.go @@ -0,0 +1,28 @@ +package billingexpr_test + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/pkg/billingexpr" + "github.com/stretchr/testify/require" +) + +// The visual editor emits JSON-quoted strings. The Go expression runtime must +// preserve those strings and its established unknown-timezone UTC fallback. +func TestTimeFunctionsJSONQuotedTimezoneCompatibility(t *testing.T) { + for _, timezone := range []string{"UTC", `Test/"quote`, `Test/back\`, "Test/\nline", "Test/\tvalue"} { + t.Run(timezone, func(t *testing.T) { + quoted, err := common.Marshal(timezone) + require.NoError(t, err) + // Each hour() call reads the clock independently. Bracket the actual + // value with UTC reads so crossing an hour boundary is still valid. + expression := `let before = hour("UTC"); let actual = hour(` + string(quoted) + `); let after = hour("UTC"); + actual == before || actual == after ? tier("base", p * 1 + c * 0) : tier("fallback", p * 2 + c * 0)` + cost, trace, err := billingexpr.RunExpr(expression, billingexpr.TokenParams{P: 100}) + require.NoError(t, err) + require.Equal(t, float64(100), cost) + require.Equal(t, "base", trace.MatchedTier) + }) + } +} diff --git a/relay/channel/openai/chat_stream.go b/relay/channel/openai/chat_stream.go new file mode 100644 index 00000000..cfc02fa5 --- /dev/null +++ b/relay/channel/openai/chat_stream.go @@ -0,0 +1,280 @@ +package openai + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" +) + +const ( + maxPendingChatStreamEvents = 32 + maxPendingChatStreamBytes = 64 << 10 +) + +// Observe native Chat writes because legacy SSE rendering can discard writer +// errors. The scanner serializes this wrapper with ping writes. +type chatStreamWriteObserver struct { + gin.ResponseWriter + err error +} + +func (w *chatStreamWriteObserver) Write(data []byte) (int, error) { + if w.err != nil { + return 0, w.err + } + n, err := w.ResponseWriter.Write(data) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + w.err = err + return n, err +} + +func (w *chatStreamWriteObserver) WriteString(data string) (int, error) { + return w.Write([]byte(data)) +} + +func (w *chatStreamWriteObserver) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +// Keep the raw delta fields for the visibility decision. The forwarding path +// still owns formatting; unknown provider payloads must not become empty just +// because the typed Chat DTO does not yet describe them. +type chatStreamFrame struct { + Error json.RawMessage `json:"error"` + Usage *dto.Usage `json:"usage"` + Choices []struct { + Delta map[string]any `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` +} + +func chatStreamValueHasPayload(value any) bool { + switch value := value.(type) { + case nil: + return false + case string: + return strings.TrimSpace(value) != "" + case []any: + return len(value) > 0 + case map[string]any: + return len(value) > 0 + default: + // Be conservative with provider extensions: never silently replay an + // output whose semantics we cannot classify. + return true + } +} + +func (frame *chatStreamFrame) hasPayload() bool { + for _, choice := range frame.Choices { + if choice.FinishReason == "content_filter" { + return true + } + for key, value := range choice.Delta { + if key != "role" && chatStreamValueHasPayload(value) { + return true + } + } + } + return false +} + +func (frame *chatStreamFrame) upstreamError() *types.MaxAPIError { + if len(frame.Error) == 0 || strings.TrimSpace(string(frame.Error)) == "null" { + return nil + } + var upstream types.OpenAIError + if err := common.Unmarshal(frame.Error, &upstream); err != nil { + // Some compatible providers send a string instead of an error object. + _ = common.Unmarshal(frame.Error, &upstream.Message) + } + if strings.TrimSpace(upstream.Message) == "" { + upstream.Message = "upstream returned a stream error" + } + if upstream.Type == "" { + upstream.Type = "upstream_error" + } + apiErr := types.WithOpenAIError(upstream, http.StatusBadGateway) + if chatStreamRequestErrorIsPermanent(upstream) { + types.ErrOptionWithSkipRetry()(apiErr) + } + return apiErr +} + +// Reject invalid requests without replaying them, but leave channel-specific +// credentials, quota, model availability and transient failures to routing. +func chatStreamRequestErrorIsPermanent(upstream types.OpenAIError) bool { + code, _ := upstream.Code.(string) + switch strings.ToLower(strings.TrimSpace(code)) { + case "rate_limit_exceeded", "insufficient_quota", "invalid_api_key", "model_not_found", "server_error", "overloaded_error": + return false + case "invalid_request", "invalid_request_error", "context_length_exceeded", "invalid_prompt", "content_policy_violation", "invalid_parameter", "unsupported_parameter", "missing_required_parameter", "invalid_value", "invalid_type": + return true + } + return strings.EqualFold(strings.TrimSpace(upstream.Type), "invalid_request_error") +} + +// A failed attempt with no payload returns nil usage and can reach the retry +// policy. A partial response returns usage AND a SkipRetry error; TextHelper +// settles that usage before returning the terminal error to the controller. +func oaiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + if info.RetryIndex > 0 && !c.Writer.Written() { + info.StreamStatus = nil + info.SendResponseCount, info.ReceivedResponseCount = 0, 0 + info.ThinkingContentInfo = relaycommon.ThinkingContentInfo{IsFirstThinkingContent: true} + } + usage := &dto.Usage{} + containUsage, hasPayload := false, false + var text strings.Builder + var toolCount int + var lastData string + var streamErr *types.MaxAPIError + pending := make([]string, 0, 4) + pendingBytes := 0 + buffering := shouldRetryEmptyCompletion(c, info) + observer := newOpenAIStreamToolCallObserver(info) + writeObserver := &chatStreamWriteObserver{ResponseWriter: c.Writer} + c.Writer = writeObserver + defer func() { + // Keep terminal write failures latched through the controller's error + // response; appending SSE events after a partial frame corrupts it. + if writeObserver.err == nil { + c.Writer = writeObserver.ResponseWriter + } + }() + + send := func(data string) bool { + err := HandleStreamFormat(c, info, data, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + if err == nil { + err = writeObserver.err + } + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusBadGateway) + if c.Writer.Written() { + types.ErrOptionWithSkipRetry()(streamErr) + } + return false + } + return true + } + recordUsage := func(frame *chatStreamFrame, data string) { + lastData = data + if frame.Usage != nil { + usage, containUsage = frame.Usage, true + } + } + flushPending := func() bool { + buffering = false + for _, data := range pending { + if !send(data) { + return false + } + } + pending = nil + pendingBytes = 0 + return true + } + + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + var frame chatStreamFrame + if err := common.UnmarshalJsonStr(data, &frame); err != nil { + streamErr = types.NewOpenAIError(fmt.Errorf("invalid upstream chat stream: %w", err), types.ErrorCodeBadResponseBody, http.StatusBadGateway) + sr.Stop(streamErr) + return + } + // Inspect the current error before releasing any preceding role frame. + if streamErr = frame.upstreamError(); streamErr != nil { + sr.Stop(streamErr) + return + } + visible := frame.hasPayload() + if !info.ShouldIncludeUsage && frame.Usage != nil && len(frame.Choices) == 0 { + recordUsage(&frame, data) + return + } + if buffering { + if visible || !shouldRetryEmptyCompletion(c, info) || len(pending) >= maxPendingChatStreamEvents || pendingBytes+len(data) > maxPendingChatStreamBytes { + if !flushPending() { + sr.Stop(streamErr) + return + } + } else { + recordUsage(&frame, data) + pending = append(pending, data) + pendingBytes += len(data) + return + } + } + if !send(data) { + sr.Stop(streamErr) + return + } + hasPayload = hasPayload || visible + recordUsage(&frame, data) + observeOpenAIStreamToolCalls(observer, data) + if err := processTokenData(info.RelayMode, data, &text, &toolCount); err != nil { + sr.Error(err) + } + }) + + // The scanner has joined its workers, so a concurrent ping/write cannot + // race the final retry decision. Cancellation is never an empty completion. + if c.Request.Context().Err() != nil { + streamErr = types.NewOpenAIError(c.Request.Context().Err(), types.ErrorCodeBadResponse, http.StatusBadGateway, types.ErrOptionWithSkipRetry()) + } else if streamErr == nil && !info.StreamStatus.IsNormalEnd() { + streamErr = types.NewOpenAIError(fmt.Errorf("upstream chat stream ended: %s", info.StreamStatus.Summary()), types.ErrorCodeBadResponse, http.StatusBadGateway) + } + if streamErr != nil { + if c.Writer.Written() { + types.ErrOptionWithSkipRetry()(streamErr) + } + if !hasPayload { + return nil, streamErr + } + } else if !hasPayload { + willRetry := shouldRetryEmptyCompletion(c, info) + recordEmptyCompletion(c, info, usage, "empty_chat_stream_output", nil, willRetry) + if willRetry { + return nil, newEmptyCompletionRetryError() + } + } + + if !containUsage { + usage = service.ResponseText2Usage(c, text.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + usage.CompletionTokens += toolCount * 7 + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastData)) + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, text.String()) + } + if streamErr != nil { + return usage, streamErr + } + if !flushPending() { + if !hasPayload { + return nil, streamErr + } + return usage, streamErr + } + if hasPayload { + recordEmptyCompletionRetrySuccess(c, info, usage) + } + + var metadata dto.ChatCompletionsStreamResponse + _ = common.UnmarshalJsonStr(lastData, &metadata) + HandleFinalResponse(c, info, lastData, metadata.Id, metadata.Created, metadata.Model, metadata.GetSystemFingerprint(), usage, containUsage) + return usage, nil +} diff --git a/relay/channel/openai/chat_stream_test.go b/relay/channel/openai/chat_stream_test.go new file mode 100644 index 00000000..7b0f2231 --- /dev/null +++ b/relay/channel/openai/chat_stream_test.go @@ -0,0 +1,472 @@ +package openai + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/setting/operation_setting" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const chatRoleFrame = `{"id":"resp_test","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}` +const chatOverloadFrame = `{"error":{"message":"Our servers are currently overloaded. Please try again later.","type":"upstream_error"}}` +const chatUsageFrame = `{"id":"","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":38,"completion_tokens":0,"total_tokens":38}}` + +func newChatStreamTest(t *testing.T) (*gin.Context, *httptest.ResponseRecorder, *relaycommon.RelayInfo) { + t.Helper() + gin.SetMode(gin.TestMode) + oldEnabled, oldRetries := common.EmptyCompletionRetryEnabled, common.RetryTimes + common.EmptyCompletionRetryEnabled, common.RetryTimes = true, 2 + t.Cleanup(func() { common.EmptyCompletionRetryEnabled, common.RetryTimes = oldEnabled, oldRetries }) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeChatCompletions, RelayFormat: types.RelayFormatOpenAI, + DisablePing: true, IsStream: true, ShouldIncludeUsage: true, + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-test"}, + } + info.SetEstimatePromptTokens(38) + return c, recorder, info +} + +func replayChatStream(c *gin.Context, info *relaycommon.RelayInfo, frames ...string) (*dto.Usage, *types.MaxAPIError) { + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("data: " + strings.Join(frames, "\n\ndata: ") + "\n\n"))} + return OaiStreamHandler(c, info, resp) +} + +func TestNativeChatStreamOverloadBeforeOutput(t *testing.T) { + for _, force := range []bool{false, true} { + for _, role := range []bool{false, true} { + t.Run(fmt.Sprintf("force=%t/role=%t", force, role), func(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + originalWriter := c.Writer + info.ChannelSetting.ForceFormat = force + frames := []string{chatOverloadFrame, chatUsageFrame, "[DONE]"} + if role { + frames = append([]string{chatRoleFrame}, frames...) + } + usage, apiErr := replayChatStream(c, info, frames...) + require.NotNil(t, apiErr) + require.Nil(t, usage) + require.Equal(t, http.StatusBadGateway, apiErr.StatusCode) + require.Equal(t, "upstream_error", apiErr.ToOpenAIError().Type) + require.Contains(t, apiErr.Error(), "overloaded") + require.False(t, types.IsSkipRetryError(apiErr)) + require.False(t, c.Writer.Written()) + require.Empty(t, recorder.Body.String()) + require.Same(t, originalWriter, c.Writer, "healthy writer must be restored for an uncommitted error") + }) + } + } +} + +func TestNativeChatStreamEmptyRetriesOnceBeforeWriting(t *testing.T) { + for _, done := range []bool{false, true} { + t.Run(map[bool]string{false: "eof", true: "done"}[done], func(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + frames := []string{chatRoleFrame, chatUsageFrame} + if done { + frames = append(frames, "[DONE]") + } + usage, apiErr := replayChatStream(c, info, frames...) + require.NotNil(t, apiErr) + require.Equal(t, types.ErrorCodeEmptyCompletion, apiErr.GetErrorCode()) + require.Nil(t, usage) + require.Empty(t, recorder.Body.String()) + info.RetryIndex++ + usage, apiErr = replayChatStream(c, info, frames...) + require.Nil(t, apiErr) + require.Equal(t, 38, usage.PromptTokens) + require.Equal(t, 1, emptyCompletionRetryCount(c)) + require.Equal(t, "empty_again", emptyCompletionInfo(c)["empty_retry_result"]) + }) + } +} + +func TestNativeChatStreamPreservesNonTextAndZeroUsageOutput(t *testing.T) { + for name, delta := range map[string]string{ + "text": `{"content":"hello"}`, "reasoning": `{"reasoning_content":"thinking"}`, + "refusal": `{"refusal":"cannot comply"}`, "audio": `{"audio":{"data":"YWJj"}}`, + "image": `{"content":[{"type":"image_url","image_url":{"url":"test-image"}}]}`, + "tool": `{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}`, + "legacy_tool": `{"function_call":{"name":"lookup","arguments":"{}"}}`, + } { + t.Run(name, func(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + originalWriter := c.Writer + frame := `{"choices":[{"index":0,"delta":` + delta + `,"finish_reason":null}]}` + usage, apiErr := replayChatStream(c, info, chatRoleFrame, frame, chatUsageFrame, "[DONE]") + require.Nil(t, apiErr) + require.Equal(t, 38, usage.PromptTokens) + require.Contains(t, recorder.Body.String(), frame) + require.Equal(t, 0, emptyCompletionRetryCount(c)) + require.Same(t, originalWriter, c.Writer, "successful streams restore the healthy writer") + }) + } +} + +func TestNativeChatStreamPartialFailureReturnsUsageWithoutRetry(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + text := `{"choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}` + usage, apiErr := replayChatStream(c, info, chatRoleFrame, text, chatUsageFrame, chatOverloadFrame, "[DONE]") + require.NotNil(t, apiErr) + require.True(t, types.IsSkipRetryError(apiErr)) + require.NotNil(t, usage, "partial output must still reach the existing settlement path") + require.Equal(t, 38, usage.PromptTokens) + require.Contains(t, recorder.Body.String(), "hello") + require.NotContains(t, recorder.Body.String(), "[DONE]", "outer error responder owns terminal framing") +} + +func TestNativeChatStreamEmptyGuards(t *testing.T) { + for _, guard := range []string{"disabled", "exhausted", "specific", "written"} { + t.Run(guard, func(t *testing.T) { + c, _, info := newChatStreamTest(t) + switch guard { + case "disabled": + common.EmptyCompletionRetryEnabled = false + case "exhausted": + info.RetryIndex = common.RetryTimes + case "specific": + c.Set("specific_channel_id", 1) + case "written": + _, _ = c.Writer.WriteString(": ping\n\n") + } + _, apiErr := replayChatStream(c, info, chatRoleFrame, chatUsageFrame, "[DONE]") + require.Nil(t, apiErr) + require.Zero(t, emptyCompletionRetryCount(c)) + }) + } +} + +func TestNativeChatStreamCancellationDoesNotTriggerEmptyRetry(t *testing.T) { + c, _, info := newChatStreamTest(t) + ctx, cancel := context.WithCancel(c.Request.Context()) + c.Request = c.Request.WithContext(ctx) + cancel() + _, apiErr := replayChatStream(c, info, chatRoleFrame) + require.NotNil(t, apiErr) + require.True(t, types.IsSkipRetryError(apiErr)) + require.Zero(t, emptyCompletionRetryCount(c)) +} + +func TestNativeChatStreamSuccessfulRetryRecordsWinningUsage(t *testing.T) { + c, _, info := newChatStreamTest(t) + _, apiErr := replayChatStream(c, info, chatRoleFrame, chatUsageFrame, "[DONE]") + require.NotNil(t, apiErr) + info.RetryIndex++ + usage, apiErr := replayChatStream(c, info, `{"choices":[{"delta":{"content":"hello"}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`, "[DONE]") + require.Nil(t, apiErr) + require.Equal(t, 12, usage.TotalTokens) + require.Equal(t, "success", emptyCompletionInfo(c)["empty_retry_result"]) + require.False(t, info.StreamStatus.HasErrors(), "failed-attempt status must not contaminate successful attempt") + _, ok := c.Get(string(constant.ContextKeyEmptyCompletionInfo)) + require.True(t, ok) +} + +func TestNativeChatStreamBufferLimitsCloseRetryWindow(t *testing.T) { + for _, limit := range []string{"events", "bytes"} { + t.Run(limit, func(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + frames := make([]string, maxPendingChatStreamEvents+1) + for i := range frames { + frames[i] = chatRoleFrame + } + if limit == "bytes" { + frames = []string{`{"padding":"` + strings.Repeat("x", maxPendingChatStreamBytes) + `","choices":[{"delta":{"role":"assistant"}}]}`} + } + allFrames := make([]string, len(frames)+1) + copy(allFrames, frames) + allFrames[len(frames)] = chatOverloadFrame + _, apiErr := replayChatStream(c, info, allFrames...) + require.NotNil(t, apiErr) + require.True(t, types.IsSkipRetryError(apiErr)) + require.NotEmpty(t, recorder.Body.String()) + }) + } +} + +type observedChatWriter struct { + gin.ResponseWriter + wrote chan struct{} +} + +func (w *observedChatWriter) Write(data []byte) (int, error) { + n, err := w.ResponseWriter.Write(data) + select { + case w.wrote <- struct{}{}: + default: + } + return n, err +} +func (w *observedChatWriter) WriteString(data string) (int, error) { return w.Write([]byte(data)) } + +func TestNativeChatStreamFlushesFirstPayloadWithoutWaitingForAnotherFrame(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + wrote := make(chan struct{}, 1) + c.Writer = &observedChatWriter{ResponseWriter: c.Writer, wrote: wrote} + r, w := io.Pipe() + defer r.Close() + defer w.Close() + producer := make(chan error, 1) + go func() { + _, err := io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"first\"}}]}\n\n") + if err == nil { + select { + case <-wrote: + case <-time.After(3 * time.Second): + err = fmt.Errorf("first payload was not flushed") + } + } + producer <- err + _ = w.Close() + }() + _, apiErr := OaiStreamHandler(c, info, &http.Response{StatusCode: 200, Body: r}) + require.NoError(t, <-producer) + require.Nil(t, apiErr) + require.Contains(t, recorder.Body.String(), "first") +} + +func TestNativeChatStreamPingNeverAllowsRetryAfterCommit(t *testing.T) { + c, _, info := newChatStreamTest(t) + info.DisablePing = false + settings := operation_setting.GetGeneralSetting() + oldEnabled, oldSeconds := settings.PingIntervalEnabled, settings.PingIntervalSeconds + settings.PingIntervalEnabled, settings.PingIntervalSeconds = true, 1 + t.Cleanup(func() { settings.PingIntervalEnabled, settings.PingIntervalSeconds = oldEnabled, oldSeconds }) + wrote := make(chan struct{}, 1) + c.Writer = &observedChatWriter{ResponseWriter: c.Writer, wrote: wrote} + r, w := io.Pipe() + defer r.Close() + defer w.Close() + producer := make(chan error, 1) + go func() { + _, err := io.WriteString(w, "data: "+chatRoleFrame+"\n\n") + if err == nil { + select { + case <-wrote: + case <-time.After(3 * time.Second): + err = fmt.Errorf("expected ping") + } + } + if err == nil { + _, err = io.WriteString(w, "data: "+chatOverloadFrame+"\n\n") + } + producer <- err + _ = w.Close() + }() + _, apiErr := OaiStreamHandler(c, info, &http.Response{StatusCode: 200, Body: r}) + require.NoError(t, <-producer) + require.NotNil(t, apiErr) + require.True(t, types.IsSkipRetryError(apiErr)) +} + +func TestNativeChatStreamErrorSemanticsIndependentOfEmptySwitch(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + common.EmptyCompletionRetryEnabled = false + _, apiErr := replayChatStream(c, info, `{"error":{"message":"message-only failure","code":"server_error"}}`) + require.NotNil(t, apiErr) + require.Equal(t, types.ErrorCode("server_error"), apiErr.GetErrorCode()) + require.Contains(t, apiErr.Error(), "message-only failure") + require.Empty(t, recorder.Body.String()) +} + +func TestNativeChatStreamEmptyOrMalformedFrames(t *testing.T) { + for name, frames := range map[string][]string{ + "no_frames": {"[DONE]"}, + "role_only_eof": {chatRoleFrame}, + "usage_only": {chatUsageFrame, "[DONE]"}, + "whitespace": {`{"choices":[{"delta":{"content":" ","reasoning_content":"\n"}}]}`, "[DONE]"}, + "malformed": {chatRoleFrame, `{"choices":`}, + } { + t.Run(name, func(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + usage, apiErr := replayChatStream(c, info, frames...) + require.NotNil(t, apiErr) + require.Nil(t, usage) + require.Empty(t, recorder.Body.String()) + if name == "malformed" { + require.Equal(t, types.ErrorCodeBadResponseBody, apiErr.GetErrorCode()) + } else { + require.Equal(t, types.ErrorCodeEmptyCompletion, apiErr.GetErrorCode()) + } + }) + } +} + +func TestNativeChatStreamContentFilterIsNotRetried(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + _, apiErr := replayChatStream(c, info, `{"choices":[{"delta":{},"finish_reason":"content_filter"}]}`, chatUsageFrame, "[DONE]") + require.Nil(t, apiErr) + require.Contains(t, recorder.Body.String(), "content_filter") + require.Zero(t, emptyCompletionRetryCount(c)) +} + +func TestNativeChatStreamFormattingFailureTracksForwardedPayload(t *testing.T) { + for _, prefix := range []string{"none", "role", "payload"} { + t.Run(prefix, func(t *testing.T) { + c, recorder, info := newChatStreamTest(t) + info.ChannelSetting.ForceFormat = true + frames := []string{} + if prefix == "role" { + frames = append(frames, chatRoleFrame) + } else if prefix == "payload" { + frames = append(frames, `{"choices":[{"delta":{"content":"hello"}}]}`) + } + // The native envelope accepts an extension's numeric id, but the + // requested typed formatter rejects it before writing this payload. + frames = append(frames, `{"id":123,"choices":[{"delta":{"content":"never delivered"}}],"usage":{"prompt_tokens":1000,"completion_tokens":999,"total_tokens":1999}}`, "[DONE]") + usage, apiErr := replayChatStream(c, info, frames...) + require.NotNil(t, apiErr) + require.Equal(t, prefix != "none", types.IsSkipRetryError(apiErr)) + require.NotContains(t, recorder.Body.String(), "never delivered") + if prefix != "payload" { + require.Nil(t, usage, "role-only or uncommitted failures must not settle") + } else { + require.NotNil(t, usage) + require.Equal(t, 38, usage.PromptTokens) + expected := service.ResponseText2Usage(c, "hello", info.UpstreamModelName, 38) + require.Equal(t, expected.CompletionTokens, usage.CompletionTokens) + require.Contains(t, recorder.Body.String(), "hello") + } + if prefix == "none" { + require.False(t, c.Writer.Written()) + info.RetryIndex++ + usage, apiErr = replayChatStream(c, info, `{"choices":[{"delta":{"content":"winner"}}]}`, "[DONE]") + require.Nil(t, apiErr) + require.NotNil(t, usage) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "winner")) + } + }) + } +} + +type rejectedChatResponseWriter struct { + header http.Header + short bool +} + +func (w *rejectedChatResponseWriter) Header() http.Header { return w.header } +func (w *rejectedChatResponseWriter) WriteHeader(int) {} +func (w *rejectedChatResponseWriter) Flush() {} +func (w *rejectedChatResponseWriter) Write([]byte) (int, error) { + if w.short { + return 0, nil + } + return 0, io.ErrClosedPipe +} + +func TestNativeChatStreamWriteFailureDoesNotSettleFirstPayload(t *testing.T) { + for _, short := range []bool{false, true} { + t.Run(fmt.Sprintf("short=%t", short), func(t *testing.T) { + c, _, info := newChatStreamTest(t) + failed, _ := gin.CreateTestContext(&rejectedChatResponseWriter{header: make(http.Header), short: short}) + c.Writer = failed.Writer + usage, apiErr := replayChatStream(c, info, `{"choices":[{"delta":{"content":"never delivered"}}]}`, "[DONE]") + require.NotNil(t, apiErr) + require.True(t, types.IsSkipRetryError(apiErr), "Gin committed the response headers") + require.Nil(t, usage) + n, err := c.Writer.WriteString("late response") + require.Zero(t, n) + if short { + require.ErrorIs(t, err, io.ErrShortWrite) + } else { + require.ErrorIs(t, err, io.ErrClosedPipe) + } + }) + } +} + +type pingFaultChatWriter struct { + gin.ResponseWriter + short bool + pingWrites int + writeDeadlines int +} + +func (w *pingFaultChatWriter) SetWriteDeadline(time.Time) error { + w.writeDeadlines++ + return nil +} + +func (w *pingFaultChatWriter) Write(data []byte) (int, error) { + if strings.HasPrefix(string(data), ": PING") { + w.pingWrites++ + w.ResponseWriter.WriteHeaderNow() + if w.short { + return len(data) - 1, nil + } + return 0, io.ErrClosedPipe + } + return w.ResponseWriter.Write(data) +} + +func TestNativeChatStreamPingWriteFailureIsTerminal(t *testing.T) { + for _, partial := range []bool{false, true} { + for _, short := range []bool{false, true} { + t.Run(fmt.Sprintf("partial=%t/short=%t", partial, short), func(t *testing.T) { + c, _, info := newChatStreamTest(t) + info.DisablePing = false + settings := operation_setting.GetGeneralSetting() + oldEnabled, oldSeconds := settings.PingIntervalEnabled, settings.PingIntervalSeconds + oldTimeout := constant.StreamingTimeout + settings.PingIntervalEnabled, settings.PingIntervalSeconds = true, 1 + constant.StreamingTimeout = 3 + t.Cleanup(func() { + settings.PingIntervalEnabled, settings.PingIntervalSeconds = oldEnabled, oldSeconds + constant.StreamingTimeout = oldTimeout + }) + writer := &pingFaultChatWriter{ResponseWriter: c.Writer, short: short} + c.Writer = writer + r, w := io.Pipe() + defer r.Close() + defer w.Close() + producer := make(chan error, 1) + go func() { + frame := chatRoleFrame + if partial { + frame = `{"choices":[{"delta":{"content":"hello"}}]}` + } + _, err := io.WriteString(w, "data: "+frame+"\n\n") + producer <- err + }() + usage, apiErr := OaiStreamHandler(c, info, &http.Response{StatusCode: 200, Body: r}) + require.NoError(t, <-producer) + require.NotNil(t, apiErr) + require.True(t, types.IsSkipRetryError(apiErr)) + require.Contains(t, info.StreamStatus.Summary(), string(relaycommon.StreamEndReasonPingFail)) + require.Equal(t, 1, writer.pingWrites, "a failed ping must terminate without more underlying writes") + require.Positive(t, writer.writeDeadlines, "observer must preserve response-controller deadline traversal") + n, err := c.Writer.WriteString(": PING\n\n") + require.Zero(t, n) + if short { + require.ErrorIs(t, err, io.ErrShortWrite) + } else { + require.ErrorIs(t, err, io.ErrClosedPipe) + } + require.Equal(t, 1, writer.pingWrites, "late writes must remain blocked after returning to the controller") + if partial { + expected := service.ResponseText2Usage(c, "hello", info.UpstreamModelName, 38) + require.NotNil(t, usage) + require.Equal(t, expected.CompletionTokens, usage.CompletionTokens) + } else { + require.Nil(t, usage) + } + }) + } + } +} diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 156923ea..1076b588 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -13,6 +13,7 @@ import ( "github.com/MAX-API-Next/MAX-API/logger" "github.com/MAX-API-Next/MAX-API/relay/channel/openrouter" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" "github.com/MAX-API-Next/MAX-API/relay/helper" "github.com/MAX-API-Next/MAX-API/service" @@ -111,6 +112,9 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re } defer service.CloseResponseBodyGracefully(resp) + if info.RelayMode == relayconstant.RelayModeChatCompletions && info.RelayFormat == types.RelayFormatOpenAI { + return oaiChatStreamHandler(c, info, resp) + } model := info.UpstreamModelName var responseId string diff --git a/relay/chat_stream_billing_test.go b/relay/chat_stream_billing_test.go new file mode 100644 index 00000000..c47c51a4 --- /dev/null +++ b/relay/chat_stream_billing_test.go @@ -0,0 +1,264 @@ +package relay + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/model" + perfmetrics "github.com/MAX-API-Next/MAX-API/pkg/perf_metrics" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/setting/config" + "github.com/MAX-API-Next/MAX-API/setting/perf_metrics_setting" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type chatStreamBillingRecorder struct { + quotas []int + effects []*model.BillingSettlementEffect + refunds int +} + +var chatStreamMetricSequence atomic.Uint64 + +type billingChatWriteFailure struct { + *httptest.ResponseRecorder +} + +func (w *billingChatWriteFailure) Write(data []byte) (int, error) { + if strings.Contains(string(data), "undelivered") { + return 0, io.ErrClosedPipe + } + return w.ResponseRecorder.Write(data) +} + +func TestNativeChatRelayBillingOnDeliveryFailures(t *testing.T) { + for _, scenario := range []string{"format", "role_format", "partial_format", "write", "partial_write"} { + t.Run(scenario, func(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + oldFlag, oldTimes := common.EmptyCompletionRetryEnabled, common.RetryTimes + common.EmptyCompletionRetryEnabled, common.RetryTimes = true, 2 + t.Cleanup(func() { common.EmptyCompletionRetryEnabled, common.RetryTimes = oldFlag, oldTimes }) + var calls atomic.Int32 + partial := strings.HasPrefix(scenario, "partial_") + writeFailure := strings.HasSuffix(scenario, "write") + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + frames := []string{} + if calls.Add(1) > 1 { + frames = append(frames, `{"choices":[{"delta":{"content":"winner"}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`) + } else { + if scenario == "role_format" { + frames = append(frames, `{"choices":[{"delta":{"role":"assistant"}}]}`) + } else if partial { + frames = append(frames, `{"choices":[{"delta":{"content":"hello"}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`) + } + id := `"bad"` + if !writeFailure { + id = `123` + } + frames = append(frames, `{"id":`+id+`,"choices":[{"delta":{"content":"undelivered"}}],"usage":{"prompt_tokens":1000,"completion_tokens":999,"total_tokens":1999}}`) + } + _, _ = w.Write([]byte("data: " + strings.Join(append(frames, "[DONE]"), "\n\ndata: ") + "\n\n")) + })) + t.Cleanup(upstream.Close) + recorder := httptest.NewRecorder() + var writer http.ResponseWriter = recorder + if writeFailure { + writer = &billingChatWriteFailure{recorder} + } + c, _ := gin.CreateTestContext(writer) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + c.Set(string(constant.ContextKeyChannelType), constant.ChannelTypeOpenAI) + c.Set(string(constant.ContextKeyChannelBaseUrl), upstream.URL) + c.Set(string(constant.ContextKeyChannelKey), "synthetic-test-key") + c.Set(string(constant.ContextKeyOriginalModel), "gpt-test") + common.SetContextKey(c, constant.ContextKeyChannelSetting, dto.ChannelSettings{ForceFormat: true}) + billing := &chatStreamBillingRecorder{} + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeChatCompletions, RelayFormat: types.RelayFormatOpenAI, + OriginModelName: "gpt-test", StartTime: time.Now(), IsStream: true, DisablePing: true, + RequestId: "delivery-test-" + scenario, Billing: billing, + UserQuota: 1000, UserSetting: dto.UserSetting{QuotaWarningThreshold: 1}, + PriceData: types.PriceData{ModelRatio: 1, CompletionRatio: 1, GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}}, + Request: &dto.GeneralOpenAIRequest{Model: "gpt-test", Stream: common.GetPointer(true)}, + } + apiErr := TextHelper(c, info) + require.NotNil(t, apiErr) + require.Equal(t, scenario != "format", types.IsSkipRetryError(apiErr)) + require.NotContains(t, recorder.Body.String(), "undelivered") + if partial { + require.Equal(t, []int{12}, billing.quotas) + require.Len(t, billing.effects, 1) + service.HandleFailedBilling(c, info, apiErr) + require.Zero(t, billing.refunds) + } else { + require.Empty(t, billing.quotas) + require.Empty(t, billing.effects) + if scenario == "format" { + info.RetryIndex++ + require.Nil(t, TextHelper(c, info)) + require.Equal(t, []int{12}, billing.quotas) + require.Len(t, billing.effects, 1) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "winner")) + } else { + service.HandleFailedBilling(c, info, apiErr) + service.HandleFailedBilling(c, info, apiErr) + require.Equal(t, 1, billing.refunds) + } + } + }) + } +} + +func (b *chatStreamBillingRecorder) Settle(int) error { panic("expected settlement with effect") } +func (b *chatStreamBillingRecorder) SettleWithEffect(quota int, effect *model.BillingSettlementEffect) error { + b.quotas = append(b.quotas, quota) + b.effects = append(b.effects, effect) + return nil +} +func (b *chatStreamBillingRecorder) Refund(*gin.Context) { + if b.NeedsRefund() { + b.refunds++ + } +} +func (b *chatStreamBillingRecorder) NeedsRefund() bool { return len(b.quotas) == 0 && b.refunds == 0 } +func (b *chatStreamBillingRecorder) GetPreConsumedQuota() int { return 100 } +func (b *chatStreamBillingRecorder) Reserve(int) error { return nil } + +func TestNativeChatRelayBillingAcrossAttempts(t *testing.T) { + for _, failure := range []string{"overload", "empty", "partial", "all_failed"} { + t.Run(failure, func(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + oldFlag, oldTimes := common.EmptyCompletionRetryEnabled, common.RetryTimes + common.EmptyCompletionRetryEnabled, common.RetryTimes = true, 2 + t.Cleanup(func() { common.EmptyCompletionRetryEnabled, common.RetryTimes = oldFlag, oldTimes }) + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := calls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + frames := []string{`{"choices":[{"delta":{"role":"assistant"}}]}`} + if (attempt > 1 && failure != "all_failed") || failure == "partial" { + frames = append(frames, `{"choices":[{"delta":{"content":"hello"}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`) + } + if (attempt == 1 && failure != "empty") || failure == "all_failed" { + frames = append(frames, `{"error":{"message":"overloaded","type":"upstream_error"}}`) + } else if attempt == 1 { + frames = append(frames, `{"choices":[],"usage":{"prompt_tokens":38,"completion_tokens":0,"total_tokens":38}}`) + } + _, _ = w.Write([]byte("data: " + strings.Join(append(frames, "[DONE]"), "\n\ndata: ") + "\n\n")) + })) + t.Cleanup(upstream.Close) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + c.Set(string(constant.ContextKeyChannelType), constant.ChannelTypeOpenAI) + c.Set(string(constant.ContextKeyChannelBaseUrl), upstream.URL) + c.Set(string(constant.ContextKeyChannelKey), "synthetic-test-key") + c.Set(string(constant.ContextKeyOriginalModel), "gpt-test") + billing := &chatStreamBillingRecorder{} + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeChatCompletions, RelayFormat: types.RelayFormatOpenAI, + OriginModelName: "gpt-test", StartTime: time.Now(), IsStream: true, DisablePing: true, + RequestId: "chat-test-" + failure, Billing: billing, + UserQuota: 1000, UserSetting: dto.UserSetting{QuotaWarningThreshold: 1}, + PriceData: types.PriceData{ModelRatio: 1, CompletionRatio: 1, GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}}, + Request: &dto.GeneralOpenAIRequest{Model: "gpt-test", Stream: common.GetPointer(true)}, + } + apiErr := TextHelper(c, info) + require.NotNil(t, apiErr) + if failure == "partial" { + require.True(t, types.IsSkipRetryError(apiErr)) + require.Equal(t, []int{12}, billing.quotas) + service.HandleFailedBilling(c, info, apiErr) + require.Zero(t, billing.refunds) + } else { + require.Empty(t, billing.quotas, "failed attempt must not settle or project usage") + require.False(t, c.Writer.Written()) + info.RetryIndex++ + apiErr = TextHelper(c, info) + if failure == "all_failed" { + require.NotNil(t, apiErr) + service.HandleFailedBilling(c, info, apiErr) + service.HandleFailedBilling(c, info, apiErr) + require.Equal(t, 1, billing.refunds) + require.Empty(t, billing.quotas) + } else { + require.Nil(t, apiErr) + require.Equal(t, []int{12}, billing.quotas, "only winning attempt usage may settle") + require.Len(t, billing.effects, 1) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "hello")) + require.NotContains(t, recorder.Body.String(), "overloaded") + } + } + }) + } +} + +func TestNativeChatPartialBillingDoesNotRecordSuccess(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.PerfMetric{}, &model.PerfMetricFlushReceipt{})) + previousDB, previousRedis := model.DB, common.RedisEnabled + setting := config.GlobalConfig.Get("perf_metrics_setting").(*perf_metrics_setting.PerfMetricsSetting) + previousSetting := *setting + model.DB, common.RedisEnabled, setting.Enabled = db, false, true + t.Cleanup(func() { + model.DB, common.RedisEnabled = previousDB, previousRedis + *setting = previousSetting + sqlDB, _ := db.DB() + _ = sqlDB.Close() + }) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + billing := &chatStreamBillingRecorder{} + info := &relaycommon.RelayInfo{ + OriginModelName: fmt.Sprintf("partial-metric-%d", chatStreamMetricSequence.Add(1)), + StartTime: time.Now(), IsStream: true, Billing: billing, + UserQuota: 1000, UserSetting: dto.UserSetting{QuotaWarningThreshold: 1}, + ChannelMeta: &relaycommon.ChannelMeta{}, + PriceData: types.PriceData{ModelRatio: 1, CompletionRatio: 1, GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}}, + } + service.PostPartialConsumeQuota(c, info, &dto.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12}) + require.Equal(t, []int{12}, billing.quotas) + // The controller records the terminal failure once. The settlement entry + // point must not enqueue an additional successful sample for this request. + perfmetrics.RecordRelaySample(info, false, 0) + // Observe the full window on the test goroutine. Never's worker may outlive + // its timeout and query the restored/closed DB during cleanup. + deadline := time.Now().Add(200 * time.Millisecond) + for { + result, queryErr := perfmetrics.QuerySummaryAll(1, nil) + require.NoError(t, queryErr) + found := false + for _, summary := range result.Models { + if summary.ModelName == info.OriginModelName { + found = true + require.EqualValues(t, 1, summary.RequestCount) + require.Zero(t, summary.SuccessRate) + break + } + } + require.True(t, found, "missing failure metric") + if !time.Now().Before(deadline) { + break + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index f0504ac2..c60ccd68 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -214,6 +214,13 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError *types usage, maxApiErr := adaptor.DoResponse(c, httpResp, info) if maxApiErr != nil { + // A native Chat stream can fail after delivering billable output. + // Its handler returns partial usage only with SkipRetry: preserve the + // existing settlement path rather than refunding a consumed response. + if partial, ok := usage.(*dto.Usage); ok && partial != nil && types.IsSkipRetryError(maxApiErr) && + info.IsStream && info.RelayMode == relayconstant.RelayModeChatCompletions && info.RelayFormat == types.RelayFormatOpenAI { + service.PostPartialConsumeQuota(c, info, partial) + } // reset status code 重置状态码 service.ResetStatusCode(maxApiErr, statusCodeMappingStr) return maxApiErr diff --git a/service/quota.go b/service/quota.go index 13d92af7..334c0ae1 100644 --- a/service/quota.go +++ b/service/quota.go @@ -286,6 +286,10 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData) } func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) { + postAudioConsumeQuota(ctx, relayInfo, usage, extraContent, true) +} + +func postAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string, recordSuccess bool) { relayInfo.CommitToolUsageAttempt() var tieredUsedVars map[string]bool @@ -404,9 +408,11 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u Group: relayInfo.UsingGroup, Other: other, }) - gopool.Go(func() { - perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens)) - }) + if recordSuccess { + gopool.Go(func() { + perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens)) + }) + } } func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error { diff --git a/service/text_quota.go b/service/text_quota.go index b24c256f..88e7aae2 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -15,6 +15,7 @@ import ( relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" "github.com/MAX-API-Next/MAX-API/setting/operation_setting" + "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" "github.com/MAX-API-Next/MAX-API/types" "github.com/bytedance/gopkg/util/gopool" @@ -413,6 +414,21 @@ func streamFallbackQuota(relayInfo *relaycommon.RelayInfo, quota int) (int, bool } func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) { + postTextConsumeQuota(ctx, relayInfo, usage, extraContent, true) +} + +// PostPartialConsumeQuota settles delivered usage without recording a successful +// request. The controller owns the terminal error and its failure metric. +func PostPartialConsumeQuota(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.Usage) { + if (usage.CompletionTokenDetails.AudioTokens > 0 || usage.PromptTokensDetails.AudioTokens > 0) && + (ratio_setting.ContainsAudioRatio(info.OriginModelName) || ratio_setting.ContainsAudioCompletionRatio(info.OriginModelName)) { + postAudioConsumeQuota(ctx, info, usage, "partial upstream response", false) + } else { + postTextConsumeQuota(ctx, info, usage, []string{"partial upstream response"}, false) + } +} + +func postTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string, recordSuccess bool) { relayInfo.CommitToolUsageAttempt() originUsage := usage billingUsage := effectiveBillingUsage(usage) @@ -583,7 +599,9 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us Group: relayInfo.UsingGroup, Other: other, }) - gopool.Go(func() { - perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens)) - }) + if recordSuccess { + gopool.Go(func() { + perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens)) + }) + } } diff --git a/service/tiered_settle_test.go b/service/tiered_settle_test.go index 8084f443..f2618749 100644 --- a/service/tiered_settle_test.go +++ b/service/tiered_settle_test.go @@ -31,6 +31,35 @@ const probeExpr = `param("service_tier") == "fast" ? tier("fast", p * 4 + c * 20 const testQuotaPerUnit = 500_000.0 +func TestVisualZeroPriceContractKeepsNormalizationAndQuota(t *testing.T) { + for _, variable := range []string{"cr", "cc", "cc1h", "img", "img_o", "ai", "ao"} { + expr := `tier("base", p * 3 + c * 15 + ` + variable + ` * 0)` + require.True(t, billingexpr.UsedVars(expr)[variable], "zero coefficient must not remove %s from the normalization contract", variable) + } + usage := &dto.Usage{PromptTokens: 1000, CompletionTokens: 100, TotalTokens: 1100} + usage.PromptTokensDetails.CachedTokens = 800 + usage.CompletionTokenDetails.ImageTokens = 80 + for _, tc := range []struct { + expr string + p, c float64 + quota int + }{ + {`tier("base", p * 3 + c * 15)`, 1000, 100, 2250}, + {`tier("base", p * 3 + c * 15 + cr * 0)`, 200, 100, 1050}, + {`tier("base", p * 3 + c * 15 + cr * 0 + img_o * 0)`, 200, 20, 450}, + {`len < 2000 ? tier("base", p * 3 + c * 15 + cr * 0 + img_o * 0) : tier("other", p * 6 + c * 30)`, 200, 20, 450}, + } { + t.Run(tc.expr, func(t *testing.T) { + params := BuildTieredTokenParams(usage, false, billingexpr.UsedVars(tc.expr)) + require.Equal(t, tc.p, params.P) + require.Equal(t, tc.c, params.C) + ok, quota, _ := TryTieredSettle(makeRelayInfo(tc.expr, 1, 1000, 100), params) + require.True(t, ok) + require.Equal(t, tc.quota, quota) + }) + } +} + func makeSnapshot(expr string, groupRatio float64, estPrompt, estCompletion int) *billingexpr.BillingSnapshot { return &billingexpr.BillingSnapshot{ BillingMode: "tiered_expr", diff --git a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx index 57ee6a43..778d35d0 100644 --- a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx +++ b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx @@ -16,51 +16,202 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { createReactTestEnvironment } from '@/test/react' -import { cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, useState, type ReactElement } from 'react' +import { createInstance } from 'i18next' +import { JSDOM } from 'jsdom' import assert from 'node:assert/strict' import { after, afterEach, before, describe, test } from 'node:test' import { I18nextProvider } from 'react-i18next' import { MAX_MANUAL_ROUTING_GROUPS } from '../lib/api-key-form' -import { ApiKeyRoutingEditor } from './api-key-routing-editor' +import type { TokenRoutingMode } from '../types' +import type { ApiKeyGroupOption } from './api-key-group-combobox' -const testEnv = createReactTestEnvironment() +let ApiKeyRoutingEditor: typeof import('./api-key-routing-editor').ApiKeyRoutingEditor +let cleanup: typeof import('@testing-library/react/pure').cleanup +let fireEvent: typeof import('@testing-library/react/pure').fireEvent +let render: typeof import('@testing-library/react/pure').render +let within: typeof import('@testing-library/react/pure').within -before(() => testEnv.setup()) +const dom = new JSDOM('', { + url: 'http://localhost/', + pretendToBeVisual: true, +}) +const i18n = createInstance() +const previousGlobals = new Map() + +before(async (): Promise => { + await i18n.init({ lng: 'en', resources: { en: { translation: {} } } }) + const globals = { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + Node: dom.window.Node, + Event: dom.window.Event, + MouseEvent: dom.window.MouseEvent, + MutationObserver: dom.window.MutationObserver, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + ResizeObserver: class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }, + IS_REACT_ACT_ENVIRONMENT: true, + } + for (const [key, value] of Object.entries(globals)) { + previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }) + } + dom.window.HTMLElement.prototype.scrollIntoView = (): void => undefined + Object.defineProperty(dom.window, 'matchMedia', { + value: (): Pick< + MediaQueryList, + | 'matches' + | 'addListener' + | 'removeListener' + | 'addEventListener' + | 'removeEventListener' + > => ({ + matches: false, + addListener(): void {}, + removeListener(): void {}, + addEventListener(): void {}, + removeEventListener(): void {}, + }), + }) + // React and Base UI must detect the browser before mounting interactive portals. + ;({ cleanup, fireEvent, render, within } = + await import('@testing-library/react/pure')) + ;({ ApiKeyRoutingEditor } = await import('./api-key-routing-editor')) +}) -afterEach(() => cleanup()) +afterEach(async (): Promise => { + await act(async (): Promise => cleanup()) +}) -after(() => testEnv.teardown()) +after((): void => { + dom.window.close() + for (const [key, descriptor] of previousGlobals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } +}) const groups = Array.from( { length: MAX_MANUAL_ROUTING_GROUPS }, - (_, index) => `group-${index + 1}` + (_: unknown, index: number): string => `group-${index + 1}` +) + +const availableGroups = Array.from( + { length: 14 }, + (_: unknown, index: number): string => `group-${index + 1}` ) -describe('ApiKeyRoutingEditor', () => { - test('keeps the maximum manual selection in one accessible selector', (): void => { +type RoutingHarnessProps = { + initialGroups: string[] +} + +function RoutingHarness(props: RoutingHarnessProps): ReactElement { + const [mode, setMode] = useState('smart') + const [manualGroups, setManualGroups] = useState(props.initialGroups) + return ( + + ({ + value: group, + label: group, + ratio: 1, + }) + )} + onModeChange={setMode} + onRouteChange={(): void => undefined} + onManualGroupsChange={setManualGroups} + onRetryOnFailureChange={(): void => undefined} + /> + + ) +} + +describe('ApiKeyRoutingEditor', (): void => { + for (const initialGroups of [[], groups]) { + test(`clears ${initialGroups.length} stored manual selections when automatic routing is disabled`, async (): Promise => { + const view = render() + view.getByText('+11') + fireEvent.click( + view.getByRole('button', { name: 'Disable and select groups' }) + ) + + view.getByText('0 / 8 groups selected') + view.getByText('Select at least one manual routing group') + assert.equal( + view.queryAllByRole('button', { name: /^Remove group-/ }).length, + 0 + ) + + fireEvent.click(view.getByRole('combobox', { name: 'Select groups' })) + const options = await view.findAllByRole('option') + assert.equal(options.length, availableGroups.length) + for (const group of availableGroups) { + const option = view.getByRole('option', { + name: `${group}1x Ratio`, + }) + assert.notEqual(option.getAttribute('aria-disabled'), 'true') + } + fireEvent.click(view.getByRole('option', { name: 'group-141x Ratio' })) + within(view.getByRole('combobox', { name: 'Select groups' })).getByText( + 'group-14' + ) + fireEvent.click(view.getByRole('combobox', { name: 'Select groups' })) + + fireEvent.click( + view.getByRole('button', { name: 'Enable automatic routing' }) + ) + fireEvent.click( + view.getByRole('button', { name: 'Disable and select groups' }) + ) + view.getByText('0 / 8 groups selected') + }) + } + + test('keeps all available groups visible at the manual selection limit', async (): Promise => { let changedGroups: string[] | undefined const view = render( - + ({ - value: group, - label: group, - desc: group, - ratio: 1, - }))} - defaultManualGroups={groups} - onModeChange={() => undefined} - onRouteChange={() => undefined} + realGroupOptions={availableGroups.map( + (group: string): ApiKeyGroupOption => ({ + value: group, + label: group, + desc: group, + ratio: 1, + }) + )} + onModeChange={(): void => undefined} + onRouteChange={(): void => undefined} onManualGroupsChange={(nextGroups: string[]): void => { changedGroups = nextGroups }} - onRetryOnFailureChange={() => undefined} + onRetryOnFailureChange={(): void => undefined} /> ) @@ -74,6 +225,21 @@ describe('ApiKeyRoutingEditor', () => { fireEvent.click(trigger) assert.equal(trigger.getAttribute('aria-expanded'), 'true') + const options = await view.findAllByRole('option') + assert.equal(options.length, availableGroups.length) + for (const group of availableGroups) { + const option = view.getByRole('option', { + name: `${group}${group}1x Ratio`, + }) + assert.equal( + option.getAttribute('aria-disabled'), + String(!groups.includes(group)) + ) + } + fireEvent.click( + view.getByRole('option', { name: 'group-14group-141x Ratio' }) + ) + assert.equal(changedGroups, undefined) const removeButtons = view.getAllByRole('button', { name: /^Remove group-/, diff --git a/web/default/src/features/keys/components/api-key-routing-editor.tsx b/web/default/src/features/keys/components/api-key-routing-editor.tsx index c348d940..a97675da 100644 --- a/web/default/src/features/keys/components/api-key-routing-editor.tsx +++ b/web/default/src/features/keys/components/api-key-routing-editor.tsx @@ -69,7 +69,6 @@ type ApiKeyRoutingEditorProps = { retryOnFailure: boolean autoRouteOptions: ApiKeyAutoRouteOption[] realGroupOptions: ApiKeyGroupOption[] - defaultManualGroups: string[] preserveUnavailableRouting?: boolean routesLoading?: boolean disabled?: boolean @@ -432,11 +431,9 @@ export function ApiKeyRoutingEditor(props: ApiKeyRoutingEditorProps) { (option) => option.value === props.route ) - const setMode = (mode: TokenRoutingMode) => { - if (mode === 'manual' && props.manualGroups.length === 0) { - props.onManualGroupsChange( - props.defaultManualGroups.slice(0, MAX_MANUAL_ROUTING_GROUPS) - ) + const setMode = (mode: TokenRoutingMode): void => { + if (mode === 'manual') { + props.onManualGroupsChange([]) } props.onModeChange(mode) } diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx index 95f09d50..e11bbc82 100644 --- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -133,7 +133,7 @@ export function ApiKeysMutateDrawer( realGroups, autoRouteOptions, effectiveAutoRoute, - defaultManualGroups, + legacyManualGroups, } = useMemo(() => { const entries = Object.entries(groupsRaw) const real: ApiKeyGroupOption[] = entries @@ -177,7 +177,7 @@ export function ApiKeysMutateDrawer( realGroups: real, autoRouteOptions: routes, effectiveAutoRoute: route, - defaultManualGroups: + legacyManualGroups: routeGroups.length > 0 ? routeGroups : real.slice(0, 1).map((option) => option.value), @@ -187,13 +187,13 @@ export function ApiKeysMutateDrawer( const routingContextRef = useRef({ autoRouteOptions, realGroups, - defaultManualGroups, + legacyManualGroups, effectiveAutoRoute, }) routingContextRef.current = { autoRouteOptions, realGroups, - defaultManualGroups, + legacyManualGroups, effectiveAutoRoute, } const schema = useMemo( @@ -226,7 +226,7 @@ export function ApiKeysMutateDrawer( const { autoRouteOptions: availableAutoRoutes, realGroups: availableRealGroups, - defaultManualGroups: availableDefaultManualGroups, + legacyManualGroups: availableLegacyManualGroups, effectiveAutoRoute: availableAutoRoute, } = routingContextRef.current @@ -265,7 +265,7 @@ export function ApiKeysMutateDrawer( setPreservedManualGroups(unavailableManualGroups) const routeManualGroups = availableAutoRoutes.find((route) => route.value === routeKey) - ?.groups || availableDefaultManualGroups + ?.groups || availableLegacyManualGroups form.reset( transformApiKeyToFormDefaults(result.data, routeManualGroups) ) @@ -281,12 +281,7 @@ export function ApiKeysMutateDrawer( setEditingLegacyRouting(false) setPreservedSmartRoute(undefined) setPreservedManualGroups([]) - form.reset( - getApiKeyFormDefaultValues( - availableAutoRoute, - availableDefaultManualGroups - ) - ) + form.reset(getApiKeyFormDefaultValues(availableAutoRoute)) } return () => { @@ -467,7 +462,6 @@ export function ApiKeysMutateDrawer( retryOnFailure={!!crossGroupRetry} autoRouteOptions={autoRouteOptions} realGroupOptions={realGroups} - defaultManualGroups={defaultManualGroups} preserveUnavailableRouting={ Boolean(preservedSmartRoute) || preservedManualGroups.length > 0 @@ -485,17 +479,6 @@ export function ApiKeysMutateDrawer( shouldDirty: true, shouldValidate: true, }) - if (!form.getFieldState('manual_groups').isDirty) { - const routeGroups = - autoRouteOptions.find( - (option) => option.value === route - )?.groups || [] - form.setValue( - 'manual_groups', - routeGroups.slice(0, MAX_MANUAL_ROUTING_GROUPS), - { shouldValidate: true } - ) - } }} onManualGroupsChange={(groups) => form.setValue('manual_groups', groups, { diff --git a/web/default/src/features/keys/lib/api-key-form.test.ts b/web/default/src/features/keys/lib/api-key-form.test.ts index 39423803..09ce199b 100644 --- a/web/default/src/features/keys/lib/api-key-form.test.ts +++ b/web/default/src/features/keys/lib/api-key-form.test.ts @@ -23,11 +23,11 @@ const translate = ((key: string) => key) as TFunction describe('API key routing form', () => { test('defaults new keys to the configured automatic routing group', () => { - const values = getApiKeyFormDefaultValues('auto', ['base', 'default']) + const values = getApiKeyFormDefaultValues('auto') const payload = transformFormDataToPayload(values) assert.equal(values.routing_mode, 'smart') - assert.deepEqual(values.manual_groups, ['base', 'default']) + assert.deepEqual(values.manual_groups, []) assert.deepEqual(payload.routing, { version: 1, mode: 'smart', @@ -97,31 +97,40 @@ describe('API key routing form', () => { assert.equal(values.manual_groups.includes(''), false) }) - test('normalizes default manual groups to the supported maximum', () => { - const values = getApiKeyFormDefaultValues( - 'auto', - Array.from( - { length: MAX_MANUAL_ROUTING_GROUPS + 2 }, - (_, index) => `group-${index}` - ) - ) - - assert.equal(values.manual_groups.length, MAX_MANUAL_ROUTING_GROUPS) - assert.equal(values.manual_groups.includes(''), false) - + test('requires a manual selection and enforces the supported maximum', (): void => { + const values = { + ...getApiKeyFormDefaultValues(), + name: 'manual-key', + routing_mode: 'manual', + } const schema = getApiKeyFormSchema(translate, { smartRoutes: ['auto'], manualGroups: Array.from( { length: MAX_MANUAL_ROUTING_GROUPS + 2 }, - (_, index) => `group-${index}` + (_: unknown, index: number): string => `group-${index}` + ), + }) + const empty = schema.safeParse(values) + assert.equal(empty.success, false) + assert.ok( + empty.error?.issues.some((issue) => issue.path[0] === 'manual_groups') + ) + const selected = schema.safeParse({ ...values, manual_groups: ['group-9'] }) + assert.equal(selected.success, true) + const maximum = schema.safeParse({ + ...values, + manual_groups: Array.from( + { length: MAX_MANUAL_ROUTING_GROUPS }, + (_: unknown, index: number): string => `group-${index}` ), }) + assert.equal(maximum.success, true) const result = schema.safeParse({ ...values, routing_mode: 'manual', manual_groups: Array.from( { length: MAX_MANUAL_ROUTING_GROUPS + 1 }, - (_, index) => `group-${index}` + (_: unknown, index: number): string => `group-${index}` ), }) assert.equal(result.success, false) @@ -186,7 +195,7 @@ describe('API key routing form', () => { assert.equal(shouldIncludeRoutingProjection(true, true, false), false) }) - test('uses the selected smart route groups when switching to manual mode', () => { + test('does not seed manual groups when loading a smart key', (): void => { const apiKey = { id: 2, name: 'smart-key', @@ -212,7 +221,7 @@ describe('API key routing form', () => { } satisfies ApiKey const values = transformApiKeyToFormDefaults(apiKey, ['fast', 'default']) - assert.deepEqual(values.manual_groups, ['fast', 'default']) + assert.deepEqual(values.manual_groups, []) assert.equal(values.cross_group_retry, false) }) @@ -222,7 +231,7 @@ describe('API key routing form', () => { assert.equal(shouldIncludeRoutingProjection(true, false, false), true) const payload = transformFormDataToPayload( - getApiKeyFormDefaultValues('auto', ['default']), + getApiKeyFormDefaultValues('auto'), { includeRouting: false } ) diff --git a/web/default/src/features/keys/lib/api-key-form.ts b/web/default/src/features/keys/lib/api-key-form.ts index b0af43df..641e646d 100644 --- a/web/default/src/features/keys/lib/api-key-form.ts +++ b/web/default/src/features/keys/lib/api-key-form.ts @@ -146,16 +146,12 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = { } export function getApiKeyFormDefaultValues( - defaultAutoRoute: string = DEFAULT_AUTO_ROUTE_KEY, - defaultManualGroups: string[] = [] + defaultAutoRoute: string = DEFAULT_AUTO_ROUTE_KEY ): ApiKeyFormValues { - const availableManualGroups = defaultManualGroups - .filter((group) => group.trim() !== '') - .slice(0, MAX_MANUAL_ROUTING_GROUPS) return { ...API_KEY_FORM_DEFAULT_VALUES, routing_route: defaultAutoRoute, - manual_groups: availableManualGroups, + manual_groups: [], } } @@ -231,25 +227,27 @@ export function transformFormDataToPayload( */ export function transformApiKeyToFormDefaults( apiKey: ApiKey, - smartModeManualGroups: string[] = [] + legacyManualGroups: string[] = [] ): ApiKeyFormValues { const routing = apiKey.routing const legacySmart = !routing && isAutoRouteKey(apiKey.group) const mode = routing?.mode ?? (legacySmart ? 'smart' : 'manual') - const availableManualGroups = smartModeManualGroups + const availableManualGroups = legacyManualGroups .filter((group) => group.trim() !== '') .slice(0, MAX_MANUAL_ROUTING_GROUPS) const legacyGroup = apiKey.group?.trim() || '' const storedManualGroups = routing?.groups?.filter((group) => group.trim() !== '') || [] - const manualGroups = - mode === 'manual' - ? storedManualGroups.length > 0 - ? storedManualGroups - : legacyGroup - ? [legacyGroup] - : availableManualGroups - : availableManualGroups + let manualGroups: string[] = [] + if (mode === 'manual') { + if (storedManualGroups.length > 0) { + manualGroups = storedManualGroups + } else if (legacyGroup) { + manualGroups = [legacyGroup] + } else { + manualGroups = availableManualGroups + } + } return { name: apiKey.name, remain_quota_dollars: apiKey.unlimited_quota diff --git a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx index fc429649..366e1098 100644 --- a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx +++ b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx @@ -103,6 +103,10 @@ function formatConditionSummary( ): string { return conditions .map((c) => { + if (['hour', 'minute', 'weekday', 'month', 'day'].includes(c.var)) { + const timeLabel = TIME_FUNC_LABELS[c.var] || c.var + return `${t(timeLabel)} ${OP_LABELS[c.op] || c.op} ${c.value} (${c.timezone || 'UTC'})` + } const varLabel = t(VAR_LABELS[c.var] || c.var) const hint = formatTokenHint(c.value) return `${varLabel} ${OP_LABELS[c.op] || c.op} ${hint || c.value}` @@ -111,6 +115,18 @@ function formatConditionSummary( .join(' && ') } +function formatTierConditionSummary( + tier: Pick, + t: (key: string) => string +): string { + if (tier.conditionGroups && tier.conditionGroups.length > 1) { + return tier.conditionGroups + .map((group) => `(${formatConditionSummary(group, t)})`) + .join(' || ') + } + return formatConditionSummary(tier.conditions, t) +} + function describeCondition( cond: RequestCondition, t: (key: string) => string @@ -222,9 +238,10 @@ export function DynamicPricingBreakdown({ const visiblePriceFields = BILLING_PRICING_VARS.filter((v) => { if (!hasTiers) return false if (hideCacheColumns && v.group === 'cache') return false - return tiers.some( - (tier) => Number(tier[v.field as string as keyof ParsedTier] || 0) > 0 - ) + return tiers.some((tier) => { + const value = tier[v.field as string] + return typeof value === 'number' && Number.isFinite(value) + }) }) return ( @@ -250,7 +267,7 @@ export function DynamicPricingBreakdown({
{tiers.map((tier, i) => { - const condSummary = formatConditionSummary(tier.conditions, t) + const condSummary = formatTierConditionSummary(tier, t) const isMatched = matchedTierLabel != null && matchedTierLabel !== '' && @@ -286,16 +303,14 @@ export function DynamicPricingBreakdown({ )}
{visiblePriceFields.map((v) => { - const value = Number( - tier[v.field as string as keyof ParsedTier] || 0 - ) + const value = tier[v.field as string] return (
{t(v.shortLabel)}
- {value > 0 + {typeof value === 'number' && Number.isFinite(value) ? `${symbol}${(value * rate).toFixed(4)}` : '-'}
@@ -326,7 +341,7 @@ export function DynamicPricingBreakdown({ {tiers.map((tier, i) => { - const condSummary = formatConditionSummary(tier.conditions, t) + const condSummary = formatTierConditionSummary(tier, t) const isMatched = normalizedMatchedTierLabel !== '' && normalizeTierLabel(tier.label) === @@ -363,15 +378,14 @@ export function DynamicPricingBreakdown({ )} {visiblePriceFields.map((v) => { - const value = Number( - tier[v.field as string as keyof ParsedTier] || 0 - ) + const value = tier[v.field as string] return ( - {value > 0 ? ( + {typeof value === 'number' && + Number.isFinite(value) ? ( {`${symbol}${(value * rate).toFixed(4)}`} diff --git a/web/default/src/features/pricing/components/model-details.runtime.test.tsx b/web/default/src/features/pricing/components/model-details.runtime.test.tsx index 0671b48c..4be1239a 100644 --- a/web/default/src/features/pricing/components/model-details.runtime.test.tsx +++ b/web/default/src/features/pricing/components/model-details.runtime.test.tsx @@ -18,9 +18,11 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createReactTestEnvironment } from '@/test/react' +import { within } from '@testing-library/react' import assert from 'node:assert/strict' import { after, before, describe, test } from 'node:test' import type { PricingModel } from '../types' +import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' import { ModelDetailsContent } from './model-details' const testEnv = createReactTestEnvironment() @@ -29,6 +31,91 @@ before(() => testEnv.setup()) after(() => testEnv.teardown()) +for (const expression of [ + 'len < 100 && c == 5 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)', + 'len < 100 ? tier("peak", p * 2 + c * 4) : max(p, 1)', + 'len < 9007199254740993 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)', + 'len < 1e-999 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)', + 'tier("custom", max(p, 1) * 3 + c * 15)', + 'tier("custom", p * 1e999 + c * 15)', + 'tier("custom", p * 1e-999 + c * 15)', + 'len < 100 ? tier("short", p * 1 + c * 2)', +]) { + test(`shows the complete raw expression when structured parsing is unsupported: ${expression}`, async (): Promise => { + const view = await testEnv.render( + + ) + try { + const pricing = within(view.container) + assert.ok(pricing.getByText('Unable to parse structured pricing')) + assert.ok(pricing.getByText(expression, { exact: true })) + assert.equal(pricing.queryByRole('table'), null) + } finally { + await view.unmount() + } + }) +} + +test('shows configured zero coefficients and leaves absent coefficients blank in both layouts', async (): Promise => { + const view = await testEnv.render( + + ) + try { + const pricing = within(view.container) + const table = pricing.getByRole('table') + const desktop = within(table) + assert.equal(desktop.getAllByRole('columnheader').length, 10) + const freeRow = desktop.getByRole('row', { name: /^free / }) + const baseRow = desktop.getByRole('row', { name: /^base / }) + assert.deepEqual( + within(freeRow) + .getAllByRole('cell') + .slice(1) + .map((cell: HTMLElement): string | null => cell.textContent), + Array(9).fill('$0.0000') + ) + assert.deepEqual( + within(baseRow) + .getAllByRole('cell') + .slice(3) + .map((cell: HTMLElement): string | null => cell.textContent), + Array(7).fill('-') + ) + // Mobile cards render outside the semantic desktop table. + const mobilePrices = (price: string): HTMLElement[] => + pricing + .getAllByText(price, { exact: true }) + .filter((element: HTMLElement): boolean => !table.contains(element)) + assert.equal(mobilePrices('$0.0000').length, 9) + assert.equal(mobilePrices('-').length, 7) + assert.equal(mobilePrices('$2.0000').length, 1) + assert.equal(mobilePrices('$4.0000').length, 1) + } finally { + await view.unmount() + } +}) + +test('still hides cache columns when the request has no cache usage', async (): Promise => { + const view = await testEnv.render( + + ) + try { + const headers = within(view.container) + .getAllByRole('columnheader') + .map((cell: HTMLElement): string | null => cell.textContent) + assert.deepEqual(headers, ['Tier', 'Input', 'Output', 'Image In']) + } finally { + await view.unmount() + } +}) + describe('ModelDetailsContent structured task pricing', () => { function createModel(inputVideoMaxQuantity?: number): PricingModel { return { diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index f22e68c3..1eb7313b 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -522,7 +522,7 @@ function PriceSection(props: { {t('Default quantity')}
- {card.default_quantity || 1} + {card.default_quantity ?? 1} {t(card.unit || 'unit')} @@ -1028,7 +1028,7 @@ function GroupPricingSection(props: { {availableGroups.map((group) => { - const ratio = props.groupRatio[group] || 1 + const ratio = props.groupRatio[group] ?? 1 return ( @@ -1122,7 +1122,7 @@ function GroupPricingSection(props: { />
{availableGroups.map((group) => { - const ratio = props.groupRatio[group] || 1 + const ratio = props.groupRatio[group] ?? 1 return (
@@ -1232,7 +1232,7 @@ function GroupPricingSection(props: { {availableGroups.map((group) => { - const ratio = props.groupRatio[group] || 1 + const ratio = props.groupRatio[group] ?? 1 return ( diff --git a/web/default/src/features/pricing/lib/billing-expr.ts b/web/default/src/features/pricing/lib/billing-expr.ts index 3cd4dc76..878c51a1 100644 --- a/web/default/src/features/pricing/lib/billing-expr.ts +++ b/web/default/src/features/pricing/lib/billing-expr.ts @@ -16,6 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { + isSupportedNumericLiteral, + splitTopLevelExpression, + unwrapConditionParens, +} from './expression-syntax' + /** * Billing expression parsing utilities. * @@ -160,11 +166,6 @@ export const BILLING_CACHE_VAR_MAP = BILLING_EXTRA_VARS.map((v) => ({ exprVar: v.key, })) -const BILLING_VAR_REGEX = new RegExp( - `\\b(${BILLING_PRICING_VARS.map((v) => v.key).join('|')})\\s*\\*\\s*([\\d.eE+-]+)`, - 'g' -) - // --------------------------------------------------------------------------- // Request rule constants // --------------------------------------------------------------------------- @@ -229,14 +230,16 @@ export type RequestRuleGroup = { } export type TierCondition = { - var: 'p' | 'c' | 'len' + var: 'p' | 'c' | 'len' | 'hour' | 'minute' | 'weekday' | 'month' | 'day' op: '<' | '<=' | '>' | '>=' value: number + timezone?: string } export type ParsedTier = { label: string conditions: TierCondition[] + conditionGroups?: TierCondition[][] [field: string]: unknown } @@ -251,51 +254,93 @@ function stripExprVersion(exprStr: string): { version: number; body: string } { return { version: 1, body: exprStr } } -function parseTierBody(bodyStr: string): Record { - const coeffs: Record = {} - const re = new RegExp(BILLING_VAR_REGEX.source, 'g') - let m - while ((m = re.exec(bodyStr)) !== null) { - if (!(m[1] in coeffs)) coeffs[m[1]] = Number(m[2]) - } +function parseTierBody(bodyStr: string): Record | null { + const body = bodyStr.trim() + // Consume every term and separator. Unsupported bodies must stay raw. + const termRe = + /\s*([a-z][a-z0-9_]*)\s*\*\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*/y const tier: Record = {} - for (const [varName, field] of Object.entries(BILLING_VAR_KEY_TO_FIELD)) { - tier[field] = coeffs[varName] || 0 + while (termRe.lastIndex < body.length) { + const match = termRe.exec(body) + if (!match || !Object.hasOwn(BILLING_VAR_KEY_TO_FIELD, match[1])) + return null + const field = BILLING_VAR_KEY_TO_FIELD[match[1]] + if (Object.hasOwn(tier, field) || !isSupportedNumericLiteral(match[2])) + return null + tier[field] = Number(match[2]) + if (termRe.lastIndex === body.length) return tier + if (body[termRe.lastIndex] !== '+') return null + termRe.lastIndex += 1 } - return tier + return null } export function parseTiersFromExpr(exprStr: string): ParsedTier[] { if (!exprStr) return [] try { const { body } = stripExprVersion(exprStr) - const condGroup = - `((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)` + - `(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)` - const tierRe = new RegExp( - `(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*([^)]+)\\)`, - 'g' - ) + const tierRe = /^tier\("([^"]*)",\s*([\s\S]+)\)$/ const tiers: ParsedTier[] = [] - let m - while ((m = tierRe.exec(body)) !== null) { - const condStr = m[1] || '' - const conditions: TierCondition[] = [] - if (condStr) { - for (const cp of condStr.split(/\s*&&\s*/)) { - const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/) - if (cm) { - conditions.push({ - var: cm[1] as TierCondition['var'], - op: cm[2] as TierCondition['op'], - value: Number(cm[3]), - }) - } + const branches = splitTopLevelExpression( + unwrapConditionParens(body), + ':', + true + ) + for (const [index, branch] of branches.entries()) { + const parts = branch.match(/^(.*?)\s*\?\s*(tier\("[^"]*",[\s\S]+\))$/) + // A complete ternary chain ends in exactly one unconditional fallback. + const expectsCondition = index < branches.length - 1 + if (Boolean(parts) !== expectsCondition) return [] + const condStr = parts?.[1]?.trim() || '' + const tierMatch = tierRe.exec(parts?.[2] || branch) + if (!tierMatch || (parts && !condStr)) return [] + const conditionGroups: TierCondition[][] = [] + let isUnconditional = false + const groups = condStr + ? splitTopLevelExpression(unwrapConditionParens(condStr), '||', true) + : [] + for (const group of groups) { + const atoms = splitTopLevelExpression( + unwrapConditionParens(group), + '&&', + true + ) + if ( + atoms.length > 0 && + atoms.every((atom) => unwrapConditionParens(atom) === 'true') + ) { + // Still validate the remaining groups before using structured pricing. + isUnconditional = true + continue + } + const groupConditions: TierCondition[] = [] + for (const cp of atoms) { + const atom = unwrapConditionParens(cp) + if (atom === 'true') continue + const cm = atom.match( + /^(?:(p|c|len)|((?:hour|minute|weekday|month|day))\("((?:[^"\\]|\\.)*)"\))\s*(<=|>=|<|>)\s*([\d.eE+-]+)$/ + ) + // A partial condition would advertise a different pricing contract. + if (!cm || !isSupportedNumericLiteral(cm[5])) return [] + groupConditions.push({ + var: (cm[1] || cm[2]) as TierCondition['var'], + op: cm[4] as TierCondition['op'], + value: Number(cm[5]), + ...(cm[2] ? { timezone: JSON.parse(`"${cm[3]}"`) as string } : {}), + }) } + if (groupConditions.length > 0) conditionGroups.push(groupConditions) + } + const prices = parseTierBody(tierMatch[2]) + if (!prices) return [] + const tier: ParsedTier = { + ...prices, + label: tierMatch[1], + conditions: isUnconditional ? [] : conditionGroups.flat(), + } + if (!isUnconditional && conditionGroups.length > 1) { + tier.conditionGroups = conditionGroups } - const tier = parseTierBody(m[3]) as ParsedTier - tier.label = m[2] - tier.conditions = conditions tiers.push(tier) } return tiers diff --git a/web/default/src/features/pricing/lib/dynamic-price.ts b/web/default/src/features/pricing/lib/dynamic-price.ts index 474aadb7..ef3e62c6 100644 --- a/web/default/src/features/pricing/lib/dynamic-price.ts +++ b/web/default/src/features/pricing/lib/dynamic-price.ts @@ -138,8 +138,8 @@ export function getDynamicPriceEntries( return BILLING_PRICING_VARS.flatMap((variable) => { if (!variable.field) return [] - const value = Number(tier[variable.field]) - if (!Number.isFinite(value) || value <= 0) return [] + const value = tier[variable.field] + if (typeof value !== 'number' || !Number.isFinite(value)) return [] return [ { diff --git a/web/default/src/features/pricing/lib/expression-syntax.ts b/web/default/src/features/pricing/lib/expression-syntax.ts new file mode 100644 index 00000000..0731000e --- /dev/null +++ b/web/default/src/features/pricing/lib/expression-syntax.ts @@ -0,0 +1,97 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +// Split only outside calls and quoted strings; both pricing parsers share this contract. +export function splitTopLevelExpression( + source: string, + operator: ':' | '&&' | '||', + preserveEmpty: boolean = false +): string[] { + const parts: string[] = [] + let start = 0 + let depth = 0 + let quote = '' + let escaped = false + for (let index = 0; index < source.length; index += 1) { + const char = source[index] + if (quote) { + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === quote) quote = '' + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '(') depth += 1 + else if (char === ')') depth = Math.max(0, depth - 1) + if (depth === 0 && source.startsWith(operator, index)) { + parts.push(source.slice(start, index).trim()) + start = index + operator.length + index += operator.length - 1 + } + } + parts.push(source.slice(start).trim()) + return preserveEmpty ? parts : parts.filter(Boolean) +} + +// Reject non-finite values, unsafe integer magnitudes, and nonzero underflow. +export function isSupportedNumericLiteral(source: string): boolean { + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(source)) + return false + const value = Number(source) + return ( + Number.isFinite(value) && + (!Number.isInteger(value) || Number.isSafeInteger(value)) && + (value !== 0 || !/[1-9]/.test(source.split(/[eE]/)[0])) + ) +} + +// Remove enclosing parentheses without interpreting quoted punctuation. +export function unwrapConditionParens(source: string): string { + let value = source.trim() + while (value.startsWith('(') && value.endsWith(')')) { + let depth = 0 + let quote = '' + let closesAtEnd = false + let escaped = false + for (let index = 0; index < value.length; index += 1) { + const char = value[index] + if (quote) { + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === quote) quote = '' + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '(') depth += 1 + else if (char === ')') depth -= 1 + if (depth === 0) { + closesAtEnd = index === value.length - 1 + break + } + } + if (!closesAtEnd) break + value = value.slice(1, -1).trim() + } + return value +} diff --git a/web/default/src/features/pricing/lib/price.test.ts b/web/default/src/features/pricing/lib/price.test.ts new file mode 100644 index 00000000..efc82ef0 --- /dev/null +++ b/web/default/src/features/pricing/lib/price.test.ts @@ -0,0 +1,99 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import type { PricingModel } from '../types' +import { parseTiersFromExpr } from './billing-expr' +import { getDynamicPriceEntries } from './dynamic-price' +import { + formatFixedPrice, + formatGroupPrice, + formatTaskRateCardRange, +} from './price' + +function modelWithRateCard( + min: number | undefined, + max: number | undefined +): PricingModel { + return { + id: 1, + model_name: 'example-task', + quota_type: 1, + model_ratio: 0, + completion_ratio: 0, + enable_groups: [], + task_rate_card: { + unit: 'second', + min_unit_price: min as number, + max_unit_price: max as number, + rows: [], + }, + } +} + +describe('task rate-card display prices', () => { + test('dynamic summaries keep configured zero prices and omit absent prices', () => { + const [tier] = parseTiersFromExpr('tier("free", p * 0 + c * 2 + cr * 0)') + const entries = getDynamicPriceEntries(tier, { tokenUnit: 'M' }) + assert.deepEqual( + entries.map(({ key, value }) => [key, value]), + [ + ['p', 0], + ['c', 2], + ['cr', 0], + ] + ) + assert.match(entries[0].formatted, /0/) + assert.match(entries[2].formatted, /0/) + }) + + test('keeps an explicit zero price visible', () => { + assert.match(formatTaskRateCardRange(modelWithRateCard(0, 0)), /0/) + }) + + test('does not turn an omitted price into a misleading zero', () => { + assert.equal( + formatTaskRateCardRange(modelWithRateCard(undefined, undefined)), + '-' + ) + }) + + test('keeps explicit zero group ratios for token and per-request prices', () => { + const model = { ...modelWithRateCard(1, 1), model_price: 4 } + const free = { free: 0 } + const fixed = formatFixedPrice(model, 'free', false, 1, 1, free) + const token = formatGroupPrice( + { ...model, quota_type: 0, model_ratio: 3 }, + 'free', + 'input', + 'M', + false, + 1, + 1, + free + ) + assert.equal( + fixed, + formatFixedPrice({ ...model, model_price: 0 }, 'base', false, 1, 1, {}) + ) + assert.equal( + token, + formatGroupPrice( + { ...model, quota_type: 0, model_ratio: 0 }, + 'base', + 'input', + 'M', + false, + 1, + 1, + {} + ) + ) + }) +}) diff --git a/web/default/src/features/pricing/lib/price.ts b/web/default/src/features/pricing/lib/price.ts index 2ccc0620..056bcbe9 100644 --- a/web/default/src/features/pricing/lib/price.ts +++ b/web/default/src/features/pricing/lib/price.ts @@ -211,7 +211,7 @@ export function formatGroupPrice( return '-' } - const ratio = groupRatio[group] || 1 + const ratio = groupRatio[group] ?? 1 let priceInUSD = calculateTokenPrice(model, type, ratio) priceInUSD = applyRechargeRate( @@ -244,7 +244,7 @@ export function formatFixedPrice( return '-' } - const ratio = groupRatio[group] || 1 + const ratio = groupRatio[group] ?? 1 let priceInUSD = (model.model_price || 0) * ratio priceInUSD = applyRechargeRate( @@ -336,8 +336,12 @@ export function formatTaskRateCardRange( const groupRatio = model.group_ratio || {} const multiplier = groupRatioMultiplier ?? getMinGroupRatio(enableGroups, groupRatio) - const minPrice = Number(card.min_unit_price ?? 0) - const maxPrice = Number(card.max_unit_price ?? minPrice) + const minPrice = Number(card.min_unit_price) + // Keep an explicitly configured zero price visible, but do not render a + // misleading $0 when the backend omitted the rate-card bounds entirely. + if (!Number.isFinite(minPrice)) return '-' + const rawMaxPrice = Number(card.max_unit_price) + const maxPrice = Number.isFinite(rawMaxPrice) ? rawMaxPrice : minPrice const minFormatted = formatTaskRateCardUnitPrice( minPrice, showWithRecharge, diff --git a/web/default/src/features/pricing/lib/tier-expr.test.ts b/web/default/src/features/pricing/lib/tier-expr.test.ts index 991ddcf1..8129823c 100644 --- a/web/default/src/features/pricing/lib/tier-expr.test.ts +++ b/web/default/src/features/pricing/lib/tier-expr.test.ts @@ -18,7 +18,15 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import assert from 'node:assert/strict' import { describe, test } from 'node:test' -import { evalExprLocally, type ExtraTokenValues } from './tier-expr' +import { BILLING_CACHE_VAR_MAP, parseTiersFromExpr } from './billing-expr' +import { + evalExprLocally, + generateExprFromVisualConfig, + normalizeVisualTier, + createDefaultVisualConfig, + tryParseVisualConfig, + type ExtraTokenValues, +} from './tier-expr' const emptyExtraTokens: ExtraTokenValues = { cacheReadTokens: 0, @@ -30,8 +38,156 @@ const emptyExtraTokens: ExtraTokenValues = { audioOutputTokens: 0, } -describe('evalExprLocally', () => { - test('evaluates backend-style ternaries and logical operators', () => { +for (const expression of [ + 'tier("base", p * 0 + c * 15)', + 'len < 100 ? tier("low", p * 0 + c * 1) : tier("high", p * 2 + c * 3)', +]) { + test(`preserves outer wrappers in price displays: ${expression}`, (): void => { + const expected = parseTiersFromExpr(expression) + assert.ok(expected.length > 0) + for (const wrapped of [`(${expression})`, `v1: (( ${expression} ))`]) { + assert.deepEqual(parseTiersFromExpr(wrapped), expected) + } + assert.deepEqual(parseTiersFromExpr(`(${expression} + unknown)`), []) + }) +} + +for (const timezone of [ + 'UTC', + 'Test/"quote', + 'Test/back\\', + 'Test/\\"', + 'Test/\nline', + 'Test/\tvalue', +]) { + test(`round-trips escaped timezone strings: ${JSON.stringify(timezone)}`, (): void => { + const config = { + tiers: [ + normalizeVisualTier({ + label: 'day', + conditions: [{ var: 'hour', timezone, op: '>=', value: 9 }], + input_unit_cost: 1, + output_unit_cost: 2, + }), + normalizeVisualTier({ + label: 'base', + input_unit_cost: 3, + output_unit_cost: 4, + }), + ], + } + const expression = generateExprFromVisualConfig(config) + assert.ok(expression.includes(`hour(${JSON.stringify(timezone)})`)) + const parsed = tryParseVisualConfig(expression) + assert.ok(parsed) + assert.equal(parsed.tiers[0].conditions[0].timezone, timezone) + assert.equal(generateExprFromVisualConfig(parsed), expression) + assert.equal( + parseTiersFromExpr(expression)[0]?.conditions[0]?.timezone, + timezone + ) + }) +} + +describe('visual optional price presence', (): void => { + for (const { field, exprVar } of BILLING_CACHE_VAR_MAP) { + test(`preserves absent, zero and positive ${exprVar} prices through normalization and round trips`, (): void => { + const absent = normalizeVisualTier({ + input_unit_cost: 3, + output_unit_cost: 15, + }) + assert.equal(absent[field], undefined) + assert.doesNotMatch( + generateExprFromVisualConfig({ tiers: [absent] }), + new RegExp(`\\b${exprVar} \\*`) + ) + for (const price of [0, 0.25]) { + const source = `tier("base", p * 3 + c * 15 + ${exprVar} * ${price})` + const parsed = tryParseVisualConfig(source) + assert.ok(parsed) + assert.equal(parsed.tiers[0][field], price) + assert.equal(generateExprFromVisualConfig(parsed), source) + assert.deepEqual( + tryParseVisualConfig(generateExprFromVisualConfig(parsed)), + parsed + ) + const multi = `len < 100 ? ${source} : tier("other", p * 6 + c * 30)` + const multiParsed = tryParseVisualConfig(multi) + assert.ok(multiParsed) + assert.equal(multiParsed.tiers[1][field], undefined) + assert.equal(generateExprFromVisualConfig(multiParsed), multi) + } + }) + } + + test('does not create zero-priced subcategories in default tiers', (): void => { + assert.equal( + generateExprFromVisualConfig(createDefaultVisualConfig()), + 'tier("base", p * 0 + c * 0)' + ) + }) + + test('accepts equivalent numeric spellings when switching to visual mode', (): void => { + for (const source of [ + 'tier("base", p * 3.0 + c * 15.0)', + 'tier("base", p * 3e0 + c * 1.5e1)', + ]) { + const parsed = tryParseVisualConfig(source) + assert.ok(parsed) + assert.equal(parsed.tiers[0].input_unit_cost, 3) + assert.equal(parsed.tiers[0].output_unit_cost, 15) + } + }) + + test('preserves numeric spellings and zero presence in every optional category', (): void => { + for (const { field, exprVar } of BILLING_CACHE_VAR_MAP) { + for (const zero of ['0.0', '0e0']) { + const source = `tier("base 3.0", p * 3.0 + c * 1.5e1 + ${exprVar} * ${zero})` + for (const expression of [ + source, + `len < 1e2 ? ${source} : tier("other", p * .5 + c * 2.0)`, + ]) { + const parsed = tryParseVisualConfig(expression) + assert.ok(parsed, expression) + assert.equal(parsed.tiers[0].label, 'base 3.0') + assert.equal(parsed.tiers[0][field], 0) + assert.equal(parsed.tiers[0].input_unit_cost, 3) + assert.equal(parsed.tiers[0].output_unit_cost, 15) + assert.deepEqual( + tryParseVisualConfig(generateExprFromVisualConfig(parsed)), + parsed + ) + } + } + } + }) + + test('still rejects lossy or malformed visual conversions', (): void => { + for (const source of [ + 'tier("base", p * 1+2 + c * 15)', + 'tier("base", p * 1e999 + c * 15)', + 'tier("base", p * 1e-999 + c * 15)', + 'len < 9007199254740993 ? tier("base", p * 3 + c * 15) : tier("other", p * 6 + c * 30)', + 'tier("base", p * 3 + c * 15 + max(cr, 1))', + 'hour("Asia/Shanghai") > 99 ? tier("base", p * 3 + c * 15) : tier("other", p * 6 + c * 30)', + 'len < 100 && c == 5 ? tier("base", p * 3 + c * 15) : tier("other", p * 6 + c * 30)', + ': tier("base", p * 3 + c * 15)', + 'tier("base", p * 3 + c * 15) :', + 'len < 100 ? tier("base", p * 3 + c * 15) : : tier("other", p * 6 + c * 30)', + ]) + assert.equal(tryParseVisualConfig(source), null, source) + }) + + test('keeps every zero-priced subcategory referenced in the billing contract', (): void => { + const source = `tier("base", p * 3 + c * 15${BILLING_CACHE_VAR_MAP.map(({ exprVar }) => ` + ${exprVar} * 0`).join('')})` + const parsed = tryParseVisualConfig(source) + assert.ok(parsed) + assert.equal(generateExprFromVisualConfig(parsed), source) + }) +}) + +describe('evalExprLocally', (): void => { + test('evaluates backend-style ternaries and logical operators', (): void => { const result = evalExprLocally( 'v1:p <= 100 && c > 0 ? tier("small", p * 2 + c * 3) : tier("large", p * 4)', 100, @@ -42,7 +198,7 @@ describe('evalExprLocally', () => { assert.deepEqual(result, { cost: 230, matchedTier: 'small', error: null }) }) - test('evaluates time-based pricing expressions with backend time functions', () => { + test('evaluates time-based pricing expressions with backend time functions', (): void => { const result = evalExprLocally( 'hour("Asia/Shanghai") < 9 || (hour("Asia/Shanghai") >= 12 && hour("Asia/Shanghai") < 14) || hour("Asia/Shanghai") >= 18 ? tier("平常时段 0-9/12-14/18-24", p * 4.5 + c * 13.5 + cr * 0.15) : tier("高峰期 9-12/14-18", p * 9 + c * 27 + cr * 0.3)', 100, @@ -69,7 +225,7 @@ describe('evalExprLocally', () => { ) }) - test('keeps time helpers in backend ranges and falls back to UTC', () => { + test('keeps time helpers in backend ranges and falls back to UTC', (): void => { const utcFallbackChecks = ['hour', 'minute', 'weekday', 'month', 'day'] .flatMap((helper) => [ `${helper}("Invalid/Zone") == ${helper}("UTC")`, @@ -93,7 +249,7 @@ describe('evalExprLocally', () => { assert.deepEqual(result, { cost: 42, matchedTier: 'valid', error: null }) }) - test('does not expose browser globals or member access', () => { + test('does not expose browser globals or member access', (): void => { const result = evalExprLocally( 'globalThis.document ? 999 : 1', 100, @@ -106,3 +262,390 @@ describe('evalExprLocally', () => { assert.ok(result.error) }) }) + +describe('visual tier conditions', (): void => { + test('rejects unsupported or unsafe complete tier bodies', (): void => { + for (const body of [ + 'max(p, 1) * 3 + c * 15', + 'p * 2 * 3 + c * 4', + 'p * 2 - c * 4', + 'p * 2 + unknown * 3', + 'xp * 2 + c * 4', + 'p * 2 + p * 3', + 'p * 1e999 + c * 4', + 'p * 1e-999 + c * 4', + 'p * 9007199254740993 + c * 4', + 'p * 2 +', + 'p * 2 + 10', + '', + ]) { + assert.deepEqual(parseTiersFromExpr(`tier("custom", ${body})`), [], body) + assert.deepEqual( + parseTiersFromExpr( + `len < 100 ? tier("known", p * 1 + c * 2) : tier("custom", ${body})` + ), + [], + body + ) + } + }) + + test('rejects conditional expressions without a complete fallback chain', (): void => { + for (const expression of [ + 'len < 100 ? tier("short", p * 1 + c * 2)', + 'tier("short", p * 1 + c * 2) : tier("long", p * 2 + c * 4)', + 'len < 100 ? tier("short", p * 1 + c * 2) : len < 200 ? tier("mid", p * 2 + c * 4)', + ]) { + assert.deepEqual(parseTiersFromExpr(expression), [], expression) + } + }) + + test('retains coefficient presence, zero, and supported exponent notation', (): void => { + const [tier] = parseTiersFromExpr( + 'tier("free", c * 1e+2 + p * .5 + cr * 0 + cc1h * 0e-99)' + ) + assert.equal(tier.inputPrice, 0.5) + assert.equal(tier.outputPrice, 100) + assert.equal(tier.cacheReadPrice, 0) + assert.equal(tier.cacheCreate1hPrice, 0) + assert.equal(Object.hasOwn(tier, 'cacheCreatePrice'), false) + for (const { exprVar } of BILLING_CACHE_VAR_MAP) { + const absent = parseTiersFromExpr('tier("base", p * 2 + c * 4)')[0] + const present = parseTiersFromExpr( + `tier("base", p * 2 + c * 4 + ${exprVar} * 0)` + )[0] + assert.equal( + Object.keys(present).length, + Object.keys(absent).length + 1, + exprVar + ) + } + }) + + test('rejects structured displays with unsupported or missing branches', (): void => { + for (const expression of [ + 'len < 100 ? tier("peak", p * 2 + c * 4) : max(p, 1)', + 'len < 100 ? max(p, 1) : tier("off", p * 1 + c * 2)', + 'len < 100 ? tier("peak", p * 2 + c * 4) : : tier("off", p * 1 + c * 2)', + ': tier("off", p * 1 + c * 2)', + 'tier("off", p * 1 + c * 2) :', + ]) { + assert.deepEqual(parseTiersFromExpr(expression), [], expression) + } + }) + + test('rejects unsafe integer and underflowed condition literals', (): void => { + for (const literal of [ + '9007199254740993', + '-9007199254740993', + '1e-999', + '-1e-999', + ]) { + const expression = `len < ${literal} ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)` + assert.deepEqual(parseTiersFromExpr(expression), [], literal) + assert.equal(tryParseVisualConfig(expression), null, literal) + } + for (const literal of [ + '0', + '-0', + '0e-999', + '1e3', + '1.25', + '9007199254740991', + ]) { + const expression = `len < ${literal} ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)` + assert.equal( + parseTiersFromExpr(expression)[0]?.conditions[0]?.value, + Number(literal) + ) + } + }) + + test('rejects incomplete structured displays for unsupported conditions', (): void => { + for (const condition of [ + 'len < 100 && c == 5', + '(len < 100) || (c == 5)', + 'len < 100 && (c > 1 || c < 5)', + 'param("service_tier") == "priority" && len < 100', + '(len < 100) || (true) || (c == 5)', + 'len < 1e999', + 'len < 1+2', + 'len < 100 &&', + '|| len < 100', + '(len < 100) || ()', + ]) { + assert.deepEqual( + parseTiersFromExpr( + `${condition} ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)` + ), + [], + condition + ) + } + }) + + test('preserves wrapped true AND members in supported display conditions', (): void => { + const tiers = parseTiersFromExpr( + '(true) && len < 100 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)' + ) + assert.deepEqual(tiers[0].conditions, [{ var: 'len', op: '<', value: 100 }]) + }) + + for (const condition of [ + 'true || len < 100', + '(len < 100) || (len > 200) || ((true))', + '((len < 100) || (true && (true)))', + ]) { + test(`displays an unconditional OR tier without restrictions: ${condition}`, (): void => { + const source = `${condition} ? tier("always", p * 2 + c * 4) : tier("off", p * 1 + c * 2)` + const display = parseTiersFromExpr(source) + assert.equal(display.length, 2) + assert.deepEqual(display[0].conditions, []) + assert.equal(display[0].conditionGroups, undefined) + // Display simplification must not enable a lossy visual rewrite. + assert.equal(tryParseVisualConfig(source), null) + assert.deepEqual(evalExprLocally(source, 150, 2, emptyExtraTokens), { + cost: 308, + matchedTier: 'always', + error: null, + }) + }) + } + + test('retains restrictions when true is an AND member rather than an OR branch', (): void => { + const tiers = parseTiersFromExpr( + '(true && len < 100) || (len > 200) ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)' + ) + assert.deepEqual(tiers[0].conditionGroups, [ + [{ var: 'len', op: '<', value: 100 }], + [{ var: 'len', op: '>', value: 200 }], + ]) + }) + + for (const condition of [ + '((len < 100) || (len > 200))', + '((((len < 100) || (len > 200))))', + '((len < 100 && hour("Test/)||(:offset") >= 9) || (len > 200))', + ]) { + test(`preserves fully wrapped OR conditions: ${condition}`, (): void => { + const source = `${condition} ? tier("peak", p * 2 + c * 4 + cr * 0) : tier("off", p * 1 + c * 2)` + const display = parseTiersFromExpr(source) + assert.equal(display[0]?.conditionGroups?.length, 2) + assert.equal(display[0].conditionGroups![0][0].value, 100) + assert.equal(display[0].conditionGroups![1][0].value, 200) + const visual = tryParseVisualConfig(source) + assert.ok(visual) + assert.deepEqual( + visual.tiers[0].conditionGroups?.map((group) => group.conditions), + display[0].conditionGroups + ) + assert.equal(visual.tiers[0].cache_read_unit_cost, 0) + assert.equal(visual.tiers[1].cache_read_unit_cost, undefined) + const regenerated = generateExprFromVisualConfig(visual) + assert.deepEqual(tryParseVisualConfig(regenerated), visual) + assert.deepEqual(parseTiersFromExpr(regenerated), display) + for (const len of [0, 99, 100, 150, 200, 201]) { + assert.deepEqual( + evalExprLocally(regenerated, len, 2, emptyExtraTokens), + evalExprLocally(source, len, 2, emptyExtraTokens) + ) + } + }) + } + + test('keeps rejecting unsupported or lossy conditions inside outer wrappers', (): void => { + for (const condition of [ + '((len < 100 || len > 200) && c > 1)', + '((len < 100) || (c == 5))', + '((hour("UTC") > 99) || (len > 200))', + '((len < 9007199254740993) || (len > 200))', + ]) { + assert.equal( + tryParseVisualConfig( + `${condition} ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)` + ), + null, + condition + ) + } + }) + + for (const timezone of ['Test/)||(', 'Test/)&&(', 'Test/:offset']) { + test(`preserves quoted syntax in both pricing parsers: ${timezone}`, (): void => { + const config = { + tiers: [ + normalizeVisualTier({ + label: 'peak:night', + conditions: [], + conditionGroups: [ + { conditions: [{ var: 'hour', timezone, op: '>=', value: 9 }] }, + { conditions: [{ var: 'len', op: '<', value: 100 }] }, + ], + input_unit_cost: 2, + output_unit_cost: 4, + }), + normalizeVisualTier({ + label: 'off', + input_unit_cost: 1, + output_unit_cost: 2, + }), + ], + } + const expression = generateExprFromVisualConfig(config) + const visual = tryParseVisualConfig(expression) + const display = parseTiersFromExpr(expression) + assert.equal( + visual?.tiers[0].conditionGroups?.[0].conditions[0].timezone, + timezone + ) + assert.deepEqual( + display[0]?.conditionGroups, + config.tiers[0].conditionGroups?.map((group) => group.conditions) + ) + assert.equal(display[0]?.label, 'peak:night') + assert.equal(display.length, 2) + }) + } + + test('supports time conditions without requiring a second tier', (): void => { + const expr = generateExprFromVisualConfig({ + tiers: [ + { + label: 'business-hours', + conditions: [ + { var: 'hour', timezone: 'Asia/Shanghai', op: '>=', value: 9 }, + ], + input_unit_cost: 1, + output_unit_cost: 2, + cache_mode: 'generic', + }, + ], + }) + assert.match(expr, /hour\("Asia\/Shanghai"\) >= 9/) + assert.match(expr, /: tier\("business-hours_fallback", p \* 1 \+ c \* 2\)$/) + const parsed = tryParseVisualConfig(expr) + assert.equal(parsed?.tiers[0].conditions[0].var, 'hour') + assert.equal(parsed?.tiers[0].conditions[0].timezone, 'Asia/Shanghai') + }) + + test('keeps time conditions visible in pricing breakdown parsing', (): void => { + const tiers = parseTiersFromExpr( + 'hour("Asia/Shanghai") >= 9 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)' + ) + assert.equal(tiers[0]?.conditions[0]?.var, 'hour') + assert.equal(tiers[0]?.conditions[0]?.timezone, 'Asia/Shanghai') + }) + + test('parses OR condition groups for pricing breakdowns', (): void => { + const tiers = parseTiersFromExpr( + '(len < 200000 && hour("Asia/Shanghai") >= 9) || (weekday("Asia/Shanghai") <= 5 && month("Asia/Shanghai") >= 1) ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)' + ) + assert.equal(tiers[0]?.conditions.length, 4) + assert.equal(tiers[0]?.conditions[1]?.var, 'hour') + assert.equal(tiers[0]?.conditions[3]?.var, 'month') + }) + + test('round-trips an explicit unconditional non-final tier', (): void => { + const expr = + 'true ? tier("primary", p * 2 + c * 4) : tier("fallback", p * 1 + c * 2)' + const parsed = tryParseVisualConfig(expr) + assert.ok(parsed) + assert.equal(parsed.tiers.length, 2) + assert.equal(parsed.tiers[0].conditions.length, 0) + assert.equal(generateExprFromVisualConfig(parsed), expr) + assert.deepEqual(evalExprLocally(expr, 10, 2, emptyExtraTokens), { + cost: 28, + matchedTier: 'primary', + error: null, + }) + }) + + test('normalizes time condition values to their calendar ranges', (): void => { + const tier = normalizeVisualTier({ + conditions: [ + { var: 'month', op: '<=', value: 200000 }, + { var: 'weekday', op: '>=', value: -3 }, + ], + }) + assert.equal(tier.conditions[0].value, 12) + assert.equal(tier.conditions[1].value, 0) + }) + + test('generates more than two conditions without truncation', (): void => { + const expr = generateExprFromVisualConfig({ + tiers: [ + { + label: 'multi', + conditions: [ + { var: 'len', op: '<', value: 200000 }, + { var: 'hour', op: '>=', value: 9 }, + { var: 'weekday', op: '<=', value: 5 }, + ], + input_unit_cost: 1, + output_unit_cost: 2, + cache_mode: 'generic', + }, + ], + }) + assert.match( + expr, + /len < 200000 && hour\("Asia\/Shanghai"\) >= 9 && weekday\("Asia\/Shanghai"\) <= 5/ + ) + }) + + test('round-trips OR condition groups with AND members', (): void => { + const expr = generateExprFromVisualConfig({ + tiers: [ + { + label: 'multi-group', + conditions: [], + conditionGroups: [ + { + conditions: [ + { var: 'len', op: '<', value: 200000 }, + { + var: 'hour', + op: '>=', + value: 9, + timezone: 'Asia/Shanghai', + }, + ], + }, + { + conditions: [ + { + var: 'weekday', + op: '<=', + value: 5, + timezone: 'Asia/Shanghai', + }, + { + var: 'month', + op: '>=', + value: 1, + timezone: 'Asia/Shanghai', + }, + ], + }, + ], + input_unit_cost: 1, + output_unit_cost: 2, + cache_mode: 'generic', + }, + ], + }) + assert.match(expr, /\) \|\| \(/) + const parsed = tryParseVisualConfig(expr) + assert.equal(parsed?.tiers[0].conditionGroups?.length, 2) + assert.equal(parsed?.tiers[0].conditionGroups?.[0].conditions.length, 2) + assert.equal(parsed?.tiers[0].conditionGroups?.[1].conditions.length, 2) + assert.equal(generateExprFromVisualConfig(parsed!), expr) + }) + + test('resets invalid time values when normalizing a condition', (): void => { + const tier = normalizeVisualTier({ + conditions: [{ var: 'month', op: '<=', value: 200000 }], + }) + assert.equal(tier.conditions[0].value, 12) + }) +}) diff --git a/web/default/src/features/pricing/lib/tier-expr.ts b/web/default/src/features/pricing/lib/tier-expr.ts index 3cfae499..145fde43 100644 --- a/web/default/src/features/pricing/lib/tier-expr.ts +++ b/web/default/src/features/pricing/lib/tier-expr.ts @@ -18,20 +18,69 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { Parser, type Value } from 'expr-eval' import { BILLING_CACHE_VAR_MAP } from './billing-expr' +import { + isSupportedNumericLiteral, + splitTopLevelExpression, + unwrapConditionParens, +} from './expression-syntax' export const CACHE_MODE_TIMED = 'timed' export const CACHE_MODE_GENERIC = 'generic' export type CacheMode = typeof CACHE_MODE_TIMED | typeof CACHE_MODE_GENERIC export type TierConditionInput = { - var: 'p' | 'c' | 'len' + var: 'p' | 'c' | 'len' | 'hour' | 'minute' | 'weekday' | 'month' | 'day' op: '<' | '<=' | '>' | '>=' value: number | string + timezone?: string +} + +export type TierConditionGroup = { + conditions: TierConditionInput[] +} + +type TierConditionBounds = { min: number; max: number; defaultValue: number } + +export const TIME_CONDITION_BOUNDS: Record< + Extract< + TierConditionInput['var'], + 'hour' | 'minute' | 'weekday' | 'month' | 'day' + >, + TierConditionBounds +> = { + hour: { min: 0, max: 23, defaultValue: 9 }, + minute: { min: 0, max: 59, defaultValue: 0 }, + weekday: { min: 0, max: 6, defaultValue: 1 }, + month: { min: 1, max: 12, defaultValue: 1 }, + day: { min: 1, max: 31, defaultValue: 1 }, +} + +export function getTierConditionBounds( + variable: TierConditionInput['var'] +): TierConditionBounds | undefined { + return TIME_CONDITION_BOUNDS[variable as keyof typeof TIME_CONDITION_BOUNDS] +} + +export function normalizeTierCondition( + condition: TierConditionInput +): TierConditionInput { + const bounds = getTierConditionBounds(condition.var) + if (!bounds) return condition + const numericValue = Number(condition.value) + const value = Number.isFinite(numericValue) + ? Math.min(bounds.max, Math.max(bounds.min, numericValue)) + : bounds.defaultValue + return { + ...condition, + value, + timezone: condition.timezone ?? 'Asia/Shanghai', + } } export type VisualTier = { label: string conditions: TierConditionInput[] + conditionGroups?: TierConditionGroup[] input_unit_cost: number output_unit_cost: number cache_mode: CacheMode @@ -45,6 +94,18 @@ export type VisualTier = { [field: string]: unknown } +export function getTierConditionGroups( + tier: Pick +): TierConditionGroup[] { + if (Array.isArray(tier.conditionGroups) && tier.conditionGroups.length > 0) { + const groups = tier.conditionGroups.filter((group) => + Array.isArray(group.conditions) + ) + if (groups.length > 0) return groups + } + return tier.conditions.length > 0 ? [{ conditions: tier.conditions }] : [] +} + export type VisualConfig = { tiers: VisualTier[] } @@ -54,7 +115,7 @@ export function getTierCacheMode( ): CacheMode { if (tier?.cache_mode === CACHE_MODE_TIMED) return CACHE_MODE_TIMED if (tier?.cache_mode === CACHE_MODE_GENERIC) return CACHE_MODE_GENERIC - return Number(tier?.cache_create_1h_unit_cost) > 0 + return tier?.cache_create_1h_unit_cost != null ? CACHE_MODE_TIMED : CACHE_MODE_GENERIC } @@ -67,15 +128,31 @@ export function normalizeVisualTier( input_unit_cost: Number(tier.input_unit_cost) || 0, output_unit_cost: Number(tier.output_unit_cost) || 0, cache_mode: getTierCacheMode(tier), - conditions: Array.isArray(tier.conditions) ? tier.conditions : [], ...tier, - cache_read_unit_cost: Number(tier.cache_read_unit_cost) || 0, - cache_create_unit_cost: Number(tier.cache_create_unit_cost) || 0, - cache_create_1h_unit_cost: Number(tier.cache_create_1h_unit_cost) || 0, - image_unit_cost: Number(tier.image_unit_cost) || 0, - image_output_unit_cost: Number(tier.image_output_unit_cost) || 0, - audio_input_unit_cost: Number(tier.audio_input_unit_cost) || 0, - audio_output_unit_cost: Number(tier.audio_output_unit_cost) || 0, + conditions: Array.isArray(tier.conditions) + ? tier.conditions.map((condition) => normalizeTierCondition(condition)) + : [], + ...(Array.isArray(tier.conditionGroups) + ? { + conditionGroups: tier.conditionGroups + .filter((group) => Array.isArray(group.conditions)) + .map((group) => ({ + conditions: group.conditions.map((condition) => + normalizeTierCondition(condition) + ), + })), + } + : {}), + // Presence is part of the billing contract: an omitted subcategory stays + // in p/c, while an explicit zero excludes it and prices it for free. + ...Object.fromEntries( + BILLING_CACHE_VAR_MAP.map(({ field }) => [ + field, + tier[field] == null || tier[field] === '' + ? undefined + : Number(tier[field]), + ]) + ), } } @@ -108,11 +185,52 @@ export function normalizeVisualConfig( function buildConditionStr(conditions: TierConditionInput[]): string { if (!conditions || conditions.length === 0) return '' return conditions - .filter((c) => c.var && c.op && c.value != null && c.value !== '') - .map((c) => `${c.var} ${c.op} ${c.value}`) + .filter((c: TierConditionInput): boolean => + Boolean(c.var && c.op && c.value != null && c.value !== '') + ) + .map((c: TierConditionInput): string => { + const lhs = getTierConditionBounds(c.var) + ? `${c.var}(${JSON.stringify(String(c.timezone || 'Asia/Shanghai'))})` + : c.var + return `${lhs} ${c.op} ${c.value}` + }) .join(' && ') } +function parseTierConditionGroups(conditionStr: string): TierConditionGroup[] { + if (!conditionStr || conditionStr.trim() === 'true') return [] + const atomPattern = + /^(?:(p|c|len)|((?:hour|minute|weekday|month|day))\("((?:[^"\\]|\\.)*)"\))\s*(<=|>=|<|>)\s*([\d.eE+-]+)$/ + return splitTopLevelExpression(unwrapConditionParens(conditionStr), '||') + .map((groupStr) => unwrapConditionParens(groupStr)) + .map((groupStr) => { + const conditions: TierConditionInput[] = [] + for (const atom of splitTopLevelExpression(groupStr, '&&')) { + const match = atom.trim().match(atomPattern) + if (!match) continue + conditions.push({ + var: (match[1] || match[2]) as TierConditionInput['var'], + op: match[4] as TierConditionInput['op'], + value: Number(match[5]), + ...(match[2] + ? { timezone: JSON.parse(`"${match[3]}"`) as string } + : {}), + }) + } + return { conditions } + }) + .filter((group) => group.conditions.length > 0) +} + +function buildConditionExpr(tier: VisualTier): string { + const groups = getTierConditionGroups(tier) + .map((group) => buildConditionStr(group.conditions)) + .filter(Boolean) + if (groups.length === 0) return '' + if (groups.length === 1) return groups[0] + return groups.map((group) => `(${group})`).join(' || ') +} + function buildTierBodyExpr(tier: VisualTier): string { const parts: string[] = [] const ic = Number(tier.input_unit_cost) || 0 @@ -120,8 +238,10 @@ function buildTierBodyExpr(tier: VisualTier): string { parts.push(`p * ${ic}`) parts.push(`c * ${oc}`) for (const cv of BILLING_CACHE_VAR_MAP) { - const v = Number((tier as Record)[cv.field]) || 0 - if (v !== 0) parts.push(`${cv.exprVar} * ${v}`) + const value = tier[cv.field] + if (value != null && value !== '') { + parts.push(`${cv.exprVar} * ${Number(value)}`) + } } return parts.join(' + ') } @@ -138,9 +258,9 @@ export function generateExprFromVisualConfig( const tier = tiers[0] const label = tier.label || 'default' const body = `tier("${label}", ${buildTierBodyExpr(tier)})` - const cond = buildConditionStr(tier.conditions) + const cond = buildConditionExpr(tier) if (cond) { - return `${cond} ? ${body} : p * 0 + c * 0` + return `${cond} ? ${body} : tier("${label}_fallback", ${buildTierBodyExpr(tier)})` } return body } @@ -150,17 +270,77 @@ export function generateExprFromVisualConfig( const tier = tiers[i] const label = tier.label || `tier_${i + 1}` const body = `tier("${label}", ${buildTierBodyExpr(tier)})` - const cond = buildConditionStr(tier.conditions) + const cond = buildConditionExpr(tier) - if (i < tiers.length - 1 && cond) { + if (cond) { parts.push(`${cond} ? ${body}`) } else { - parts.push(body) + // An unconditional non-final tier is an intentional always-match + // branch, such as after removing its last condition. + parts.push(i < tiers.length - 1 ? `true ? ${body}` : body) } } + const last = tiers[tiers.length - 1] + if (buildConditionExpr(last)) { + parts.push( + `tier("${last.label || 'fallback'}_fallback", ${buildTierBodyExpr(last)})` + ) + } return parts.join(' : ') } +// Compare the visual subset without changing quoted labels or accepting +// integer rounding/underflow as a harmless spelling difference. +function canonicalizeExprForComparison(source: string): string { + let result = '' + let quote = '' + let escaped = false + let index = 0 + + while (index < source.length) { + const char = source[index] + if (quote) { + result += char + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === quote) quote = '' + index += 1 + continue + } + if (char === '"' || char === "'") { + quote = char + result += char + index += 1 + continue + } + if (/\s/.test(char)) { + index += 1 + continue + } + + const previous = source[index - 1] + const isNumberStart = + /[0-9.]/.test(char) && + !/[A-Za-z0-9_.]/.test(previous || '') && + (/[0-9]/.test(char) || /[0-9]/.test(source[index + 1] || '')) + if (isNumberStart) { + const match = source + .slice(index) + .match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/) + if (match) { + result += isSupportedNumericLiteral(match[0]) + ? String(Number(match[0])) + : match[0] + index += match[0].length + continue + } + } + result += char + index += 1 + } + return result +} + export function tryParseVisualConfig( exprStr: string | null | undefined ): VisualConfig | null { @@ -189,44 +369,48 @@ export function tryParseVisualConfig( const val = simple[4 + i] if (val != null) tier[cv.field] = Number(val) }) - return normalizeVisualConfig({ + const config = normalizeVisualConfig({ tiers: [normalizeVisualTier(tier as Partial)], }) + // Like the multi-tier parser, refuse a lossy visual conversion. + return canonicalizeExprForComparison( + generateExprFromVisualConfig(config) + ) === canonicalizeExprForComparison(body) + ? config + : null } - const condGroup = - `((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)` + - `(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)` - const tierRe = new RegExp( - `(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*${bodyPat}\\)`, - 'g' - ) + const tierRe = new RegExp(`^tier\\("([^"]*)",\\s*${bodyPat}\\)$`) const tiers: VisualTier[] = [] - let match: RegExpExecArray | null - while ((match = tierRe.exec(body)) !== null) { - const condStr = match[1] || '' - const conditions: TierConditionInput[] = [] - if (condStr) { - for (const cp of condStr.split(/\s*&&\s*/)) { - const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/) - if (cm) { - conditions.push({ - var: cm[1] as TierConditionInput['var'], - op: cm[2] as TierConditionInput['op'], - value: Number(cm[3]), - }) - } - } - } + const branches = splitTopLevelExpression(body, ':') + // The splitter omits empty parts. Reject missing branches before comparing + // conditions with only their complete outer parentheses removed. + if ( + canonicalizeExprForComparison(branches.join(':')) !== + canonicalizeExprForComparison(body) + ) + return null + const comparisonBranches: string[] = [] + for (const branch of branches) { + const parts = branch.match(/^(.*?)\s*\?\s*(tier\("[^"]*",[\s\S]+\))$/) + const condStr = parts?.[1]?.trim() || '' + const tierSource = parts?.[2] || branch + comparisonBranches.push( + parts ? `${unwrapConditionParens(condStr)} ? ${tierSource}` : branch + ) + const match = tierRe.exec(tierSource) + if (!match) continue + const conditionGroups = parseTierConditionGroups(condStr) + const conditions = conditionGroups[0]?.conditions ?? [] const tier: Record = { conditions, - input_unit_cost: Number(match[3]), - output_unit_cost: Number(match[4]), - label: match[2], + ...(conditionGroups.length > 1 ? { conditionGroups } : {}), + input_unit_cost: Number(match[2]), + output_unit_cost: Number(match[3]), + label: match[1], } - const m = match BILLING_CACHE_VAR_MAP.forEach((cv, i) => { - const val = m[5 + i] + const val = match[4 + i] if (val != null) tier[cv.field] = Number(val) }) tiers.push(normalizeVisualTier(tier as Partial)) @@ -235,7 +419,10 @@ export function tryParseVisualConfig( const cfg = normalizeVisualConfig({ tiers }) const regenerated = generateExprFromVisualConfig(cfg) - if (regenerated.replace(/\s+/g, '') !== body.replace(/\s+/g, '')) { + if ( + canonicalizeExprForComparison(regenerated) !== + canonicalizeExprForComparison(comparisonBranches.join(' : ')) + ) { return null } return cfg diff --git a/web/default/src/features/system-settings/models/download-json.ts b/web/default/src/features/system-settings/models/download-json.ts new file mode 100644 index 00000000..8f4db4a8 --- /dev/null +++ b/web/default/src/features/system-settings/models/download-json.ts @@ -0,0 +1,27 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +export function downloadJson(filename: string, value: string): void { + const blob = new Blob([value], { type: 'application/json;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + setTimeout((): void => URL.revokeObjectURL(url), 0) +} diff --git a/web/default/src/features/system-settings/models/task-rate-card-settings.runtime.test.tsx b/web/default/src/features/system-settings/models/task-rate-card-settings.runtime.test.tsx index c5258b74..6185f1f0 100644 --- a/web/default/src/features/system-settings/models/task-rate-card-settings.runtime.test.tsx +++ b/web/default/src/features/system-settings/models/task-rate-card-settings.runtime.test.tsx @@ -16,12 +16,15 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { act } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createReactTestEnvironment } from '@/test/react' -import { within } from '@testing-library/react' +import { fireEvent, within } from '@testing-library/react' import assert from 'node:assert/strict' import { after, before, describe, test } from 'node:test' +import { SettingsPageProvider } from '../components/settings-page-context' import { TaskRateCardSettings } from './task-rate-card-settings' +import { TieredBillingSettings } from './tiered-billing-settings' const testEnv = createReactTestEnvironment() @@ -29,6 +32,86 @@ before(() => testEnv.setup()) after(() => testEnv.teardown()) +for (const kind of ['task', 'tiered'] as const) { + test(`${kind} settings retain valid editor state after failed file imports`, async (): Promise => { + const queryClient = new QueryClient() + const actions = document.createElement('div') + document.body.append(actions) + const view = await testEnv.render( + + + {kind === 'task' ? ( + + ) : ( + + )} + + + ) + try { + const editor = [...view.container.querySelectorAll('textarea')].find( + (input: HTMLTextAreaElement): boolean => !input.readOnly + ) + assert.ok(editor) + const save = within(actions).getByRole('button', { + name: kind === 'task' ? 'Save task rate cards' : 'Save tiered billing', + }) as HTMLButtonElement + const fileInput = ( + kind === 'task' ? view.container : actions + ).querySelector('input[type=file]') + assert.ok(fileInput) + const original = editor.value + assert.equal(save.disabled, false) + for (const text of [ + async (): Promise => '', + async (): Promise => ' \r\n\t ', + async (): Promise => '{', + async (): Promise => '[]', + ...(kind === 'tiered' + ? ['false', 'true', 0, 1, null, [], {}].map( + (enabled: unknown): (() => Promise) => + async (): Promise => + JSON.stringify({ model: { enabled, expr: 'p * 1' } }) + ) + : []), + async (): Promise => { + throw new Error('Synthetic file read failure') + }, + ]) { + await act(async (): Promise => { + fireEvent.change(fileInput, { target: { files: [{ text }] } }) + }) + assert.equal(editor.value, original) + assert.equal(save.disabled, false) + } + const imported = + kind === 'task' + ? '{"test-model":{"unit":"second","rows":[]}}' + : '{"test-model":{"enabled":true,"expr":"p * 1"}}' + await act(async (): Promise => { + fireEvent.change(fileInput, { + target: { files: [{ text: async (): Promise => imported }] }, + }) + }) + assert.deepEqual(JSON.parse(editor.value), JSON.parse(imported)) + assert.equal(save.disabled, false) + await act(async (): Promise => { + fireEvent.change(fileInput, { + target: { files: [{ text: async (): Promise => '{}' }] }, + }) + }) + assert.deepEqual(JSON.parse(editor.value), {}) + } finally { + await view.unmount() + actions.remove() + queryClient.clear() + } + }) +} + describe('TaskRateCardSettings billing examples', () => { test('allows the active MiniMax structured example to be loaded', async () => { const queryClient = new QueryClient() diff --git a/web/default/src/features/system-settings/models/task-rate-card-settings.tsx b/web/default/src/features/system-settings/models/task-rate-card-settings.tsx index 350e2c9a..0d06c028 100644 --- a/web/default/src/features/system-settings/models/task-rate-card-settings.tsx +++ b/web/default/src/features/system-settings/models/task-rate-card-settings.tsx @@ -18,22 +18,26 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { memo, + type ChangeEvent, type ReactElement, useCallback, useEffect, useId, useMemo, + useRef, useState, } from 'react' -import { FileJson } from 'lucide-react' +import { Download, FileJson, Search, Upload } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { CopyButton } from '@/components/copy-button' import { SettingsPageActionsPortal } from '../components/settings-page-context' import { useUpdateOption } from '../hooks/use-update-option' +import { downloadJson } from './download-json' import { formatJsonForTextarea, normalizeJsonString } from './utils' const OPTION_KEY = 'task_billing_setting.rate_cards' @@ -162,6 +166,7 @@ type VendorSummary = { modelCount: number rowCount: number models: string[] + modelRows: Record } type BillingExampleSectionProps = { @@ -292,11 +297,13 @@ function buildVendorSummary(value: string): VendorSummary[] { modelCount: 0, rowCount: 0, models: [], + modelRows: {}, } group.modelCount += 1 group.rowCount += rows group.models.push(model) + group.modelRows[model] = rows groups.set(vendor, group) } @@ -311,15 +318,71 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ const currentRateCardHeadingId = useId() const [text, setText] = useState('') const [error, setError] = useState('') + const [search, setSearch] = useState('') + const [vendorFilter, setVendorFilter] = useState('all') + const fileInputRef = useRef(null) + const editRevision = useRef(0) + const initialText = useMemo( + (): string => formatJsonForTextarea(defaultValue || '{}'), + [defaultValue] + ) const vendorSummary = useMemo(() => buildVendorSummary(text), [text]) + useEffect((): void => { + if ( + vendorFilter !== 'all' && + !vendorSummary.some( + (vendor: VendorSummary): boolean => vendor.key === vendorFilter + ) + ) { + setVendorFilter('all') + } + }, [vendorFilter, vendorSummary]) + const filteredVendorSummary = useMemo((): VendorSummary[] => { + const query = search.trim().toLowerCase() + return vendorSummary + .filter( + (vendor: VendorSummary): boolean => + vendorFilter === 'all' || vendor.key === vendorFilter + ) + .map((vendor: VendorSummary): VendorSummary => { + if (!query) return vendor + return { + ...vendor, + modelCount: vendor.models.filter((model: string): boolean => + model.toLowerCase().includes(query) + ).length, + rowCount: vendor.models + .filter((model: string): boolean => + model.toLowerCase().includes(query) + ) + .reduce( + (sum: number, model: string): number => + sum + (vendor.modelRows[model] ?? 0), + 0 + ), + models: vendor.models.filter((model: string): boolean => + model.toLowerCase().includes(query) + ), + } + }) + .filter( + (vendor: VendorSummary): boolean => !query || vendor.models.length > 0 + ) + }, [search, vendorFilter, vendorSummary]) + const isDirty = text !== initialText useEffect(() => { + editRevision.current += 1 setText(formatJsonForTextarea(defaultValue || '{}')) setError('') + return () => { + editRevision.current += 1 + } }, [defaultValue]) const handleChange = useCallback( (value: string) => { + editRevision.current += 1 setText(value) try { const parsed = JSON.parse(value) as unknown @@ -337,6 +400,7 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ const handleUseExample = useCallback( (example: string): void => { + editRevision.current += 1 setText(example) setError('') toast.success(t('Example loaded. Review prices before saving.')) @@ -352,6 +416,35 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ handleUseExample(MINIMAX_RATE_CARD_EXAMPLE) }, [handleUseExample]) + const handleImport = useCallback( + async (event: ChangeEvent): Promise => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + const revision = ++editRevision.current + + try { + const contents = await file.text() + if (revision !== editRevision.current) return + const imported = formatJsonForTextarea(contents) + if (!imported) throw new Error(t('Invalid JSON')) + const parsed = JSON.parse(imported) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(t('JSON must be an object')) + } + editRevision.current += 1 + setText(imported) + setError('') + toast.success(t('JSON imported. Review prices before saving.')) + } catch (err) { + if (revision !== editRevision.current) return + const message = err instanceof Error ? err.message : t('Invalid JSON') + toast.error(message) + } + }, + [t] + ) + const handleSave = useCallback(async () => { if (error) { toast.error(error) @@ -406,9 +499,51 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ ))}
- {vendorSummary.length > 0 ? ( -
+
+
+ + ): void => + setSearch(event.target.value) + } + placeholder={t('Search model name...')} + aria-label={t('Search model name...')} + className='pl-9' + /> +
+
+ {vendorSummary.map((vendor) => ( + + ))} +
+ {isDirty && ( + + {t('Unsaved changes')} + + )} +
+ {filteredVendorSummary.length > 0 ? ( +
+ {filteredVendorSummary.map((vendor) => (
) : (
- {t('No task rate cards configured yet.')} + {vendorSummary.length > 0 + ? t('No matching task rate cards.') + : t('No task rate cards configured yet.')}
)} @@ -473,9 +610,41 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ />
-

- {t('Current rate card JSON')} -

+
+

+ {t('Current rate card JSON')} +

+
+ + + +
+