Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cb5f53d
fix
CSCITech Sep 17, 2026
acaf4c5
fix: address chat retry and pricing editor review findings
CSCITech Sep 17, 2026
80234cb
fix: preserve wrapped pricing conditions in visual conversion
CSCITech Sep 17, 2026
e32b9ab
fix: display unconditional pricing OR branches correctly
CSCITech Sep 17, 2026
b7ad074
fix: fall back for unsupported pricing conditions
CSCITech Sep 17, 2026
7e7a774
fix: clear manual groups when disabling automatic routing
CSCITech Sep 17, 2026
fac6444
fix: reject incomplete pricing displays and clarify fallback tiers
CSCITech Sep 17, 2026
ecb2b63
fix: preserve complete pricing displays and import validation
CSCITech Sep 17, 2026
8da2f92
fix: validate pricing fallbacks and configuration exports
CSCITech Sep 17, 2026
cf0c149
fix: discard stale pricing imports and flag shadowed tiers
CSCITech Sep 17, 2026
724a4b2
chore: annotate pricing settings callback return types
CSCITech Sep 17, 2026
0c782bc
fix: settle only delivered chat output and validate pricing imports
CSCITech Sep 17, 2026
c4db6ed
update
CSCITech Sep 17, 2026
1753848
chore: complete routing and pricing handler type annotations
CSCITech Sep 17, 2026
0833f82
docs: align upstream implementation plan paths
CSCITech Sep 17, 2026
33b193a
fix: keep pricing filters and imported expressions valid
CSCITech Sep 17, 2026
9c2385a
chore: annotate pricing filter and action callbacks
CSCITech Sep 17, 2026
5eb13c2
fix: harden native stream writes and pricing expression boundaries
CSCITech Sep 17, 2026
dca48c3
fix: consolidate pricing registries and strengthen review coverage
CSCITech Sep 17, 2026
3dafd05
fix: retain failed stream writer through controller error handling
CSCITech Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
3 changes: 3 additions & 0 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
27 changes: 27 additions & 0 deletions controller/relay_stream_error.go
Original file line number Diff line number Diff line change
@@ -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
}
184 changes: 184 additions & 0 deletions controller/relay_stream_error_test.go
Original file line number Diff line number Diff line change
@@ -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:")
}
28 changes: 28 additions & 0 deletions pkg/billingexpr/timezone_string_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading
Loading