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')}
+
+
+
+
+
+
+
.
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import {
+ type ChangeEvent,
+ type ReactElement,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { Braces, Check, WandSparkles } from 'lucide-react'
+import type { TFunction } from 'i18next'
+import {
+ Braces,
+ Check,
+ Download,
+ Search,
+ Upload,
+ WandSparkles,
+} 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 { updateTieredBillingConfig } from '../api'
import { SettingsForm } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
+import { downloadJson } from './download-json'
import { formatJsonForTextarea } from './utils'
type TieredBillingEntry = {
@@ -78,10 +96,13 @@ function buildUnifiedConfig(billingMode: string, billingExpr: string) {
return config
}
-function validateUnifiedConfig(value: string) {
+function validateUnifiedConfig(
+ value: string,
+ t: TFunction
+): Record {
const parsed = JSON.parse(value) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
- throw new Error('Tiered billing JSON must be an object')
+ throw new Error(t('Tiered billing JSON must be an object'))
}
const normalized: Record = {}
@@ -89,17 +110,36 @@ function validateUnifiedConfig(value: string) {
([model, entry]) => {
const name = model.trim()
if (!name) {
- throw new Error('Model name cannot be empty')
+ throw new Error(t('Model name cannot be empty'))
}
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
- throw new Error(`Config for ${name} must be an object`)
+ throw new Error(
+ t('Config for {{model}} must be an object', { model: name })
+ )
}
const rawEntry = entry as Partial
- const enabled = Boolean(rawEntry.enabled)
- const expr = typeof rawEntry.expr === 'string' ? rawEntry.expr.trim() : ''
+ if (
+ rawEntry.enabled !== undefined &&
+ typeof rawEntry.enabled !== 'boolean'
+ ) {
+ throw new Error(
+ t('Enabled flag for {{model}} must be a boolean', { model: name })
+ )
+ }
+ const enabled = rawEntry.enabled ?? false
+ if (rawEntry.expr !== undefined && typeof rawEntry.expr !== 'string') {
+ throw new Error(
+ t('Billing expression for {{model}} must be a string', {
+ model: name,
+ })
+ )
+ }
+ const expr = rawEntry.expr?.trim() ?? ''
if (enabled && !expr) {
- throw new Error(`Billing expression for ${name} cannot be empty`)
+ throw new Error(
+ t('Billing expression for {{model}} cannot be empty', { model: name })
+ )
}
normalized[name] = { enabled, expr }
}
@@ -113,6 +153,9 @@ export function TieredBillingSettings(props: TieredBillingSettingsProps) {
const queryClient = useQueryClient()
const [text, setText] = useState('')
const [error, setError] = useState('')
+ const [search, setSearch] = useState('')
+ const fileInputRef = useRef(null)
+ const editRevision = useRef(0)
const initialText = useMemo(() => {
return JSON.stringify(
@@ -123,8 +166,14 @@ export function TieredBillingSettings(props: TieredBillingSettingsProps) {
}, [props.billingExpr, props.billingMode])
useEffect(() => {
+ editRevision.current += 1
+ // The editor must reset when the server-provided model selection changes.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setText(initialText)
setError('')
+ return () => {
+ editRevision.current += 1
+ }
}, [initialText])
const mutation = useMutation({
@@ -141,8 +190,10 @@ export function TieredBillingSettings(props: TieredBillingSettingsProps) {
toast.error(err.message || t('Failed to save tiered billing config'))
},
})
+ const { mutateAsync } = mutation
- const handleFormat = useCallback(() => {
+ const handleFormat = useCallback((): void => {
+ editRevision.current += 1
try {
setText(formatJsonForTextarea(text || '{}'))
setError('')
@@ -151,15 +202,16 @@ export function TieredBillingSettings(props: TieredBillingSettingsProps) {
}
}, [t, text])
- const handleLoadExample = useCallback(() => {
+ const handleLoadExample = useCallback((): void => {
+ editRevision.current += 1
setText(JSON.stringify(EXAMPLE_CONFIG, null, 2))
setError('')
}, [])
- const handleSave = useCallback(async () => {
+ const handleSave = useCallback(async (): Promise => {
let config: Record
try {
- config = validateUnifiedConfig(text || '{}')
+ config = validateUnifiedConfig(text || '{}', t)
setError('')
} catch (err) {
const message = err instanceof Error ? err.message : t('Invalid JSON')
@@ -168,17 +220,65 @@ export function TieredBillingSettings(props: TieredBillingSettingsProps) {
return
}
- await mutation.mutateAsync({ config })
- }, [mutation, t, text])
+ await mutateAsync({ config })
+ }, [mutateAsync, t, text])
- const enabledCount = useMemo(() => {
+ const validatedConfig = useMemo((): Record<
+ string,
+ TieredBillingEntry
+ > | null => {
try {
- const config = validateUnifiedConfig(text || '{}')
- return Object.values(config).filter((entry) => entry.enabled).length
+ return validateUnifiedConfig(text || '{}', t)
} catch {
- return 0
+ return null
}
- }, [text])
+ }, [t, text])
+
+ const enabledCount = useMemo(
+ (): number =>
+ Object.values(validatedConfig ?? {}).filter((entry) => entry.enabled)
+ .length,
+ [validatedConfig]
+ )
+ const configuredModels = useMemo(
+ (): string[] => Object.keys(validatedConfig ?? {}),
+ [validatedConfig]
+ )
+
+ const filteredModels = useMemo((): string[] => {
+ const query = search.trim().toLowerCase()
+ if (!query) return configuredModels
+ return configuredModels.filter((model: string): boolean =>
+ model.toLowerCase().includes(query)
+ )
+ }, [configuredModels, search])
+
+ const isDirty = text !== initialText
+
+ 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'))
+ validateUnifiedConfig(imported, t)
+ editRevision.current += 1
+ setText(imported)
+ setError('')
+ } catch (err) {
+ if (revision !== editRevision.current) return
+ const message = err instanceof Error ? err.message : t('Invalid JSON')
+ toast.error(message)
+ }
+ },
+ [t]
+ )
return (
@@ -200,11 +300,39 @@ export function TieredBillingSettings(props: TieredBillingSettingsProps) {
>
{mutation.isPending ? t('Saving...') : t('Save tiered billing')}
+
+
+
-
+
{t('Tiered billing JSON')}
@@ -220,6 +348,26 @@ export function TieredBillingSettings(props: TieredBillingSettingsProps) {
+
+
+
+ ): void =>
+ setSearch(event.target.value)
+ }
+ placeholder={t('Search model name...')}
+ aria-label={t('Search model name...')}
+ className='pl-9'
+ />
+
+ {isDirty && (
+
+ {t('Unsaved changes')}
+
+ )}
+
+
@@ -235,9 +383,29 @@ export function TieredBillingSettings(props: TieredBillingSettingsProps) {
)}
+ {search.trim() && filteredModels.length > 0 && (
+
+ {filteredModels.map(
+ (model: string): ReactElement => (
+
+ {model}
+
+ )
+ )}
+
+ )}
+