From cb5f53d68502f674b8d3483cd84fc330f296dc1c Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 17 Sep 2026 11:55:34 +0800 Subject: [PATCH 01/20] fix --- controller/relay.go | 3 + controller/relay_stream_error.go | 27 ++ controller/relay_stream_error_test.go | 74 ++++ relay/channel/openai/chat_stream.go | 210 ++++++++++ relay/channel/openai/chat_stream_test.go | 309 ++++++++++++++ relay/channel/openai/relay-openai.go | 4 + relay/chat_stream_billing_test.go | 172 ++++++++ relay/compatible_handler.go | 7 + service/quota.go | 12 +- service/text_quota.go | 24 +- service/tiered_settle_test.go | 29 ++ .../components/dynamic-pricing-breakdown.tsx | 20 +- .../pricing/components/model-details.tsx | 8 +- .../src/features/pricing/lib/billing-expr.ts | 113 +++++- .../src/features/pricing/lib/price.test.ts | 82 ++++ web/default/src/features/pricing/lib/price.ts | 12 +- .../features/pricing/lib/tier-expr.test.ts | 248 +++++++++++- .../src/features/pricing/lib/tier-expr.ts | 349 +++++++++++++--- .../models/task-rate-card-settings.tsx | 153 ++++++- .../models/tiered-billing-settings.tsx | 135 ++++++- .../tiered-pricing-editor.runtime.test.tsx | 378 +++++++++++++++++- .../models/tiered-pricing-editor.tsx | 14 +- .../models/tiered-pricing-fields.tsx | 12 +- .../models/tiered-pricing-visual-editor.tsx | 357 +++++++++++++---- web/default/src/i18n/locales/en.json | 4 + web/default/src/i18n/locales/fr.json | 4 + web/default/src/i18n/locales/ja.json | 4 + web/default/src/i18n/locales/ru.json | 4 + web/default/src/i18n/locales/vi.json | 4 + web/default/src/i18n/locales/zh.json | 4 + 30 files changed, 2593 insertions(+), 183 deletions(-) create mode 100644 controller/relay_stream_error.go create mode 100644 controller/relay_stream_error_test.go create mode 100644 relay/channel/openai/chat_stream.go create mode 100644 relay/channel/openai/chat_stream_test.go create mode 100644 relay/chat_stream_billing_test.go create mode 100644 web/default/src/features/pricing/lib/price.test.ts 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..7c252442 --- /dev/null +++ b/controller/relay_stream_error_test.go @@ -0,0 +1,74 @@ +package controller + +import ( + "errors" + "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/setting/operation_setting" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +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 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/relay/channel/openai/chat_stream.go b/relay/channel/openai/chat_stream.go new file mode 100644 index 00000000..531f24e9 --- /dev/null +++ b/relay/channel/openai/chat_stream.go @@ -0,0 +1,210 @@ +package openai + +import ( + "encoding/json" + "fmt" + "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 +) + +// 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" + } + return types.WithOpenAIError(upstream, http.StatusBadGateway) +} + +// 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) + + send := func(data string) bool { + if err := HandleStreamFormat(c, info, data, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusBadGateway, types.ErrOptionWithSkipRetry()) + return false + } + return 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 + } + lastData = data + if frame.Usage != nil { + usage, containUsage = frame.Usage, true + } + visible := frame.hasPayload() + hasPayload = hasPayload || visible + observeOpenAIStreamToolCalls(observer, data) + if err := processTokenData(info.RelayMode, data, &text, &toolCount); err != nil { + sr.Error(err) + } + if !info.ShouldIncludeUsage && frame.Usage != nil && len(frame.Choices) == 0 { + return + } + if buffering { + if visible || !shouldRetryEmptyCompletion(c, info) || len(pending) >= maxPendingChatStreamEvents || pendingBytes+len(data) > maxPendingChatStreamBytes { + if !flushPending() { + sr.Stop(streamErr) + return + } + } else { + pending = append(pending, data) + pendingBytes += len(data) + return + } + } + if !send(data) { + sr.Stop(streamErr) + } + }) + + // 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() || hasPayload { + 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() { + 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..0ad3aa30 --- /dev/null +++ b/relay/channel/openai/chat_stream_test.go @@ -0,0 +1,309 @@ +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/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) + 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()) + }) + } + } +} + +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) + 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)) + }) + } +} + +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"}}]}`} + } + _, apiErr := replayChatStream(c, info, append(frames, chatOverloadFrame)...) + 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)) +} 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..cf18bdb6 --- /dev/null +++ b/relay/chat_stream_billing_test.go @@ -0,0 +1,172 @@ +package relay + +import ( + "fmt" + "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 + +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/pricing/components/dynamic-pricing-breakdown.tsx b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx index fc429649..9be00aa5 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 @@ -250,7 +266,7 @@ export function DynamicPricingBreakdown({
{tiers.map((tier, i) => { - const condSummary = formatConditionSummary(tier.conditions, t) + const condSummary = formatTierConditionSummary(tier, t) const isMatched = matchedTierLabel != null && matchedTierLabel !== '' && @@ -326,7 +342,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) === 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..37f2f964 100644 --- a/web/default/src/features/pricing/lib/billing-expr.ts +++ b/web/default/src/features/pricing/lib/billing-expr.ts @@ -229,14 +229,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 } @@ -269,33 +271,100 @@ 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 splitTopLevelColon = (source: string): 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) + else if (char === ':' && depth === 0) { + parts.push(source.slice(start, index).trim()) + start = index + 1 + } + } + parts.push(source.slice(start).trim()) + return parts.filter(Boolean) + } + 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+]+)$/) + for (const branch of splitTopLevelColon(body)) { + const parts = branch.match(/^(.*?)\s*\?\s*(tier\("[^"]*",[\s\S]+\))$/) + const condStr = parts?.[1]?.trim() || '' + const tierMatch = tierRe.exec(parts?.[2] || branch) + if (!tierMatch) continue + const conditionGroups: TierCondition[][] = [] + const splitLogical = (source: string, operator: '&&' | '||') => { + const parts: string[] = [] + let start = 0 + let depth = 0 + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '(') depth += 1 + else if (source[index] === ')') 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 parts.filter(Boolean) + } + const unwrap = (source: string) => { + let value = source.trim() + while (value.startsWith('(') && value.endsWith(')')) { + let depth = 0 + let closesAtEnd = false + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '(') depth += 1 + else if (value[index] === ')') depth -= 1 + if (depth === 0) { + closesAtEnd = index === value.length - 1 + break + } + } + if (!closesAtEnd) break + value = value.slice(1, -1).trim() + } + return value + } + for (const group of splitLogical(condStr, '||')) { + const groupConditions: TierCondition[] = [] + for (const cp of splitLogical(unwrap(group), '&&')) { + if (cp.trim() === 'true') continue + const cm = cp + .trim() + .match( + /^(?:(p|c|len)|((?:hour|minute|weekday|month|day))\("([^"\\]+)"\))\s*(<=|>=|<|>)\s*([\d.eE+-]+)$/ + ) if (cm) { - conditions.push({ - var: cm[1] as TierCondition['var'], - op: cm[2] as TierCondition['op'], - value: Number(cm[3]), + groupConditions.push({ + var: (cm[1] || cm[2]) as TierCondition['var'], + op: cm[4] as TierCondition['op'], + value: Number(cm[5]), + ...(cm[2] ? { timezone: cm[3] } : {}), }) } } + if (groupConditions.length > 0) conditionGroups.push(groupConditions) } - const tier = parseTierBody(m[3]) as ParsedTier - tier.label = m[2] - tier.conditions = conditions + const tier = parseTierBody(tierMatch[2]) as ParsedTier + tier.label = tierMatch[1] + tier.conditions = conditionGroups.flat() + if (conditionGroups.length > 1) tier.conditionGroups = conditionGroups tiers.push(tier) } return tiers 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..eed5d963 --- /dev/null +++ b/web/default/src/features/pricing/lib/price.test.ts @@ -0,0 +1,82 @@ +/* +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 { + 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('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..b6a545f9 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,6 +38,100 @@ const emptyExtraTokens: ExtraTokenValues = { audioOutputTokens: 0, } +describe('visual optional price presence', () => { + for (const { field, exprVar } of BILLING_CACHE_VAR_MAP) { + test(`preserves absent, zero and positive ${exprVar} prices through normalization and round trips`, () => { + 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', () => { + assert.equal( + generateExprFromVisualConfig(createDefaultVisualConfig()), + 'tier("base", p * 0 + c * 0)' + ) + }) + + test('accepts equivalent numeric spellings when switching to visual mode', () => { + 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', () => { + 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', () => { + 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)', + ]) + assert.equal(tryParseVisualConfig(source), null, source) + }) + + test('keeps every zero-priced subcategory referenced in the billing contract', () => { + 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', () => { test('evaluates backend-style ternaries and logical operators', () => { const result = evalExprLocally( @@ -106,3 +208,147 @@ describe('evalExprLocally', () => { assert.ok(result.error) }) }) + +describe('visual tier conditions', () => { + test('supports time conditions without requiring a second tier', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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..dc462f62 100644 --- a/web/default/src/features/pricing/lib/tier-expr.ts +++ b/web/default/src/features/pricing/lib/tier-expr.ts @@ -24,14 +24,54 @@ 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[] +} + +export const TIME_CONDITION_BOUNDS: Record< + Extract< + TierConditionInput['var'], + 'hour' | 'minute' | 'weekday' | 'month' | 'day' + >, + { min: number; max: number; defaultValue: number } +> = { + 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']) { + 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 +85,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 +106,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 +119,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]), + ]) + ), } } @@ -109,10 +177,109 @@ 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}`) + .map((c) => { + const lhs = ['hour', 'minute', 'weekday', 'month', 'day'].includes(c.var) + ? `${c.var}("${String(c.timezone || 'Asia/Shanghai').replace(/"/g, '\\"')}")` + : c.var + return `${lhs} ${c.op} ${c.value}` + }) .join(' && ') } +function splitTopLevelLogical(source: string, operator: '&&' | '||'): 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 parts.filter(Boolean) +} + +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 +} + +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 splitTopLevelLogical(conditionStr, '||') + .map((groupStr) => unwrapConditionParens(groupStr)) + .map((groupStr) => { + const conditions: TierConditionInput[] = [] + for (const atom of splitTopLevelLogical(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: match[3] } : {}), + }) + } + 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 +287,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 +307,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 +319,80 @@ 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) { + const numeric = Number(match[0]) + const safeNumber = + Number.isFinite(numeric) && + (!Number.isInteger(numeric) || Number.isSafeInteger(numeric)) && + (numeric !== 0 || !/[1-9]/.test(match[0].split(/[eE]/)[0])) + result += safeNumber ? String(numeric) : match[0] + index += match[0].length + continue + } + } + result += char + index += 1 + } + return result +} + export function tryParseVisualConfig( exprStr: string | null | undefined ): VisualConfig | null { @@ -189,44 +421,64 @@ 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 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 splitTopLevelColon = (source: string): 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) + else if (char === ':' && depth === 0) { + parts.push(source.slice(start, index).trim()) + start = index + 1 } } + parts.push(source.slice(start).trim()) + return parts.filter(Boolean) + } + const tierRe = new RegExp(`^tier\\("([^"]*)",\\s*${bodyPat}\\)$`) + const tiers: VisualTier[] = [] + for (const branch of splitTopLevelColon(body)) { + const parts = branch.match(/^(.*?)\s*\?\s*(tier\("[^"]*",[\s\S]+\))$/) + const condStr = parts?.[1]?.trim() || '' + const tierSource = parts?.[2] || 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 +487,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(body) + ) { return null } return cfg 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..cfd6d639 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,18 +18,21 @@ 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' @@ -152,6 +155,16 @@ const MINIMAX_RATE_CARD_EXAMPLE = JSON.stringify( 2 ) +function downloadJson(filename: string, value: string) { + 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(() => URL.revokeObjectURL(url), 0) +} + type TaskRateCardSettingsProps = { defaultValue: string } @@ -162,6 +175,7 @@ type VendorSummary = { modelCount: number rowCount: number models: string[] + modelRows: Record } type BillingExampleSectionProps = { @@ -292,11 +306,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,7 +327,36 @@ 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 initialText = useMemo( + () => formatJsonForTextarea(defaultValue || '{}'), + [defaultValue] + ) const vendorSummary = useMemo(() => buildVendorSummary(text), [text]) + const filteredVendorSummary = useMemo(() => { + const query = search.trim().toLowerCase() + return vendorSummary + .filter((vendor) => vendorFilter === 'all' || vendor.key === vendorFilter) + .map((vendor) => { + if (!query) return vendor + return { + ...vendor, + modelCount: vendor.models.filter((model) => + model.toLowerCase().includes(query) + ).length, + rowCount: vendor.models + .filter((model) => model.toLowerCase().includes(query)) + .reduce((sum, model) => sum + (vendor.modelRows[model] ?? 0), 0), + models: vendor.models.filter((model) => + model.toLowerCase().includes(query) + ), + } + }) + .filter((vendor) => !query || vendor.models.length > 0) + }, [search, vendorFilter, vendorSummary]) + const isDirty = text !== initialText useEffect(() => { setText(formatJsonForTextarea(defaultValue || '{}')) @@ -352,6 +397,30 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ handleUseExample(MINIMAX_RATE_CARD_EXAMPLE) }, [handleUseExample]) + const handleImport = useCallback( + async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + + try { + const imported = formatJsonForTextarea(await file.text()) + const parsed = JSON.parse(imported || '{}') as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(t('JSON must be an object')) + } + setText(imported || '{}') + setError('') + toast.success(t('JSON imported. Review prices before saving.')) + } catch (err) { + const message = err instanceof Error ? err.message : t('Invalid JSON') + setError(message) + toast.error(message) + } + }, + [t] + ) + const handleSave = useCallback(async () => { if (error) { toast.error(error) @@ -406,9 +475,47 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ ))}
- {vendorSummary.length > 0 ? ( -
+
+
+ + 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 +582,39 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ />
-

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

+
+

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

+
+ + + +
+