From d6b5ce99de4930f348cda8dd3bb14f739ac38e22 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 6 Aug 2026 15:56:38 +0800 Subject: [PATCH 01/18] fix(relay): set Request.GetBody so the HTTP/2 transport can transparently retry after an upstream stream reset (#6249) * fix(relay): set Request.GetBody so the HTTP/2 transport can transparently retry after an upstream stream reset The outbound request body is a type-erased io.Reader over BodyStorage, so net/http cannot derive Request.GetBody (it only does so for *bytes.Reader, *bytes.Buffer and *strings.Reader). With GetBody nil, the HTTP/2 transport cannot transparently retry a request once the body has been written and the upstream resets the stream with a retryable error (REFUSED_STREAM, or a connection-level GOAWAY); the relay request then fails with: http2: Transport: cannot retry err [...] after Request.Body was written; define Request.GetBody to avoid this error This affects every relay path that goes through DoApiRequest (chat, claude, gemini, responses, embedding, image, rerank). BodyStorage (memory and disk) already implements io.Seeker, so replay support only needed wiring: - NewOutboundJSONBody additionally returns a getBody that rewinds the storage and hands out a fresh non-closing reader. The transport only calls GetBody after the previous attempt's body has been abandoned, so the rewind cannot race an in-flight read. - RelayInfo carries it in the new UpstreamRequestGetBody field, set alongside UpstreamRequestBodySize by the handlers that build storage-backed bodies. - applyUpstreamGetBody (symmetric with applyUpstreamContentLength) wires it into DoApiRequest/DoFormRequest/DoTaskApiRequest, only when req.GetBody is still nil. Also remove the hand-rolled GetBody override in DoTaskApiRequest: it returned the same already-consumed reader, so any transport-level replay would have silently sent an empty body, and it clobbered the correct snapshot-based GetBody that net/http derives from the *bytes.Reader bodies the task adaptors pass in. For non-replayable bodies GetBody now stays nil, so a retry fails loudly instead of corrupting the request. Covered by unit tests plus an end-to-end raw-frame HTTP/2 test that resets the first stream with REFUSED_STREAM after the body is written and asserts the transport transparently retries with the complete body. Co-Authored-By: Claude Fable 5 * fix(relay): hand out independent readers from GetBody (address review) Per the http.Request.GetBody contract ("returns a new copy of Body"), each call must yield a reader with its own cursor. The previous implementation rewound and reused the shared BodyStorage, so two consecutive GetBody readers would interfere with each other, and a replay could disturb the primary body's offset under extreme transport timing (e.g. attempt N's body write not yet fully abandoned when the transport builds attempt N+1). Instead of snapshotting the payload (an extra copy), add BodyStorage.NewReader, which returns an independent zero-copy reader: - memory mode: a fresh bytes.Reader over the same immutable backing array; - disk mode: a separate file descriptor over the cache file, so the transport closing a replayed body only closes that descriptor. NewOutboundJSONBody's getBody now simply hands out storage.NewReader, and once the handler releases the storage, GetBody fails with ErrStorageClosed instead of replaying stale data. Tests: interleaved reads across two replay readers and the primary body each observe exactly their own byte stream, for both the memory and the disk-backed storage; the existing GetBody and HTTP/2 retry suites still pass (h2 e2e tests flake-free with -count=20). Co-Authored-By: Claude Fable 5 * fix(relay): bind replayable metadata on pass-through requests * fix(relay): reset upstream body metadata between channels * test(relay): cover replay across retries and channel attempts * fix(relay): stop following upstream redirects --------- Co-authored-by: Claude Fable 5 --- common/body_storage.go | 37 ++ relay/alpha_search_handler.go | 3 +- relay/channel/api_request.go | 59 +- relay/channel/api_request_getbody_test.go | 603 +++++++++++++++++++++ relay/channel/api_request_redirect_test.go | 88 +++ relay/channel/jimeng/adaptor.go | 1 + relay/channel/task/sora/adaptor.go | 2 + relay/channel/task/sora/adaptor_test.go | 40 ++ relay/chat_completions_via_responses.go | 3 +- relay/claude_handler.go | 4 +- relay/common/outbound_body.go | 18 +- relay/common/outbound_body_test.go | 163 ++++++ relay/common/relay_info.go | 17 + relay/common/relay_info_test.go | 18 + relay/compatible_handler.go | 5 +- relay/embedding_handler.go | 3 +- relay/gemini_handler.go | 8 +- relay/image_handler.go | 5 +- relay/rerank_handler.go | 5 +- relay/responses_handler.go | 5 +- 20 files changed, 1067 insertions(+), 20 deletions(-) create mode 100644 relay/channel/api_request_getbody_test.go create mode 100644 relay/channel/api_request_redirect_test.go create mode 100644 relay/channel/task/sora/adaptor_test.go create mode 100644 relay/common/outbound_body_test.go diff --git a/common/body_storage.go b/common/body_storage.go index 094dbda36d3f..0c4a849dc969 100644 --- a/common/body_storage.go +++ b/common/body_storage.go @@ -20,6 +20,13 @@ type BodyStorage interface { Size() int64 // IsDisk 是否是磁盘存储 IsDisk() bool + // NewReader returns an independent reader positioned at the start of the + // stored payload. Each call returns a reader with its own cursor, so + // callers (e.g. http.Request.GetBody) can replay the body concurrently + // with, or after, other readers without sharing seek state. Closing the + // returned reader releases only that reader, never the storage itself; + // after the storage has been closed, NewReader returns ErrStorageClosed. + NewReader() (io.ReadCloser, error) } // ErrStorageClosed 存储已关闭错误 @@ -80,6 +87,18 @@ func (m *memoryStorage) Bytes() ([]byte, error) { return m.data, nil } +func (m *memoryStorage) NewReader() (io.ReadCloser, error) { + m.mu.Lock() + defer m.mu.Unlock() + if atomic.LoadInt32(&m.closed) == 1 { + return nil, ErrStorageClosed + } + // A fresh bytes.Reader over the shared immutable backing array: an + // independent cursor at zero copy cost. NopCloser keeps Close a no-op, so + // the storage lifecycle stays owned by whoever holds the storage itself. + return io.NopCloser(bytes.NewReader(m.data)), nil +} + func (m *memoryStorage) Size() int64 { return m.size } @@ -229,6 +248,24 @@ func (d *diskStorage) Bytes() ([]byte, error) { return data, nil } +func (d *diskStorage) NewReader() (io.ReadCloser, error) { + d.mu.Lock() + defer d.mu.Unlock() + if atomic.LoadInt32(&d.closed) == 1 { + return nil, ErrStorageClosed + } + // A separate file descriptor over the same cache file: an independent + // cursor at zero copy cost. Closing the returned reader closes only that + // descriptor; the storage keeps owning the primary descriptor and the + // file's lifetime. Readers opened before Close stay usable even after the + // file is unlinked, as the descriptor keeps the inode alive. + file, err := os.Open(d.filePath) + if err != nil { + return nil, fmt.Errorf("failed to open body cache file for replay: %w", err) + } + return file, nil +} + func (d *diskStorage) Size() int64 { return d.size } diff --git a/relay/alpha_search_handler.go b/relay/alpha_search_handler.go index 23ebafea6883..c66cb1deb5b1 100644 --- a/relay/alpha_search_handler.go +++ b/relay/alpha_search_handler.go @@ -62,12 +62,13 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError } logger.LogDebug(c, "requestBody: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody adaptor := GetAdaptor(info.ApiType) if adaptor == nil { diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index fc2855a85bca..2ed9f69f1222 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -42,6 +42,33 @@ func applyUpstreamContentLength(req *http.Request, info *common.RelayInfo) { } } +// applyUpstreamGetBody populates req.GetBody when the upstream body is wrapped +// in a BodyStorage (see relay/common/outbound_body.go). +// +// net/http.NewRequest only auto-populates GetBody for *bytes.Reader, +// *bytes.Buffer and *strings.Reader. When the body is a type-erased io.Reader +// (which is the case for ReaderOnly(BodyStorage)), GetBody would otherwise stay +// nil, and the HTTP/2 transport cannot transparently retry the request once the +// upstream resets the stream after the body was already written; the request +// then fails with "http2: Transport: cannot retry err ... after Request.Body +// was written; define Request.GetBody to avoid this error". +func applyUpstreamGetBody(req *http.Request, info *common.RelayInfo) { + if info == nil || info.UpstreamRequestGetBody == nil { + return + } + if req.GetBody == nil { + req.GetBody = info.UpstreamRequestGetBody + } +} + +// ApplyUpstreamBodyMetadata restores metadata that net/http cannot infer when +// a BodyStorage is exposed through a type-erased reader. Provider adaptors +// that construct requests directly should call this before sending them. +func ApplyUpstreamBodyMetadata(req *http.Request, info *common.RelayInfo) { + applyUpstreamContentLength(req, info) + applyUpstreamGetBody(req, info) +} + func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Header) { if info.RelayMode == constant.RelayModeAudioTranscription || info.RelayMode == constant.RelayModeAudioTranslation { // multipart/form-data @@ -314,7 +341,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - applyUpstreamContentLength(req, info) + ApplyUpstreamBodyMetadata(req, info) headers := req.Header err = a.SetupRequestHeader(c, &headers, info) if err != nil { @@ -344,7 +371,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - applyUpstreamContentLength(req, info) + ApplyUpstreamBodyMetadata(req, info) // set form data req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) headers := req.Header @@ -474,11 +501,24 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error { func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { return doRequest(c, req, info) } + +// keepUpstreamRedirectResponse stops net/http from following redirects while +// returning the upstream 3xx response to the relay without an extra error. +func keepUpstreamRedirectResponse(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse +} + func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { client, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting) if err != nil { return nil, fmt.Errorf("new proxy http client failed: %w", err) } + // Clients are cached and shared across channels, so override redirect + // behavior on a shallow copy instead of mutating the cached client. This + // still reuses its transport and connection pools, including HTTP/2's + // transparent stream retries. + relayClient := *client + relayClient.CheckRedirect = keepUpstreamRedirectResponse if common2.DebugEnabled && req != nil && req.URL != nil { policy := service.NormalizeHTTPTransportPolicy(info.ChannelSetting) logger.LogDebug(c, fmt.Sprintf( @@ -510,7 +550,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http } } - resp, err := client.Do(req) + resp, err := relayClient.Do(req) if err != nil { logger.LogError(c, "do request failed: "+err.Error()) return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed")) @@ -548,10 +588,15 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, req if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - applyUpstreamContentLength(req, info) - req.GetBody = func() (io.ReadCloser, error) { - return io.NopCloser(requestBody), nil - } + ApplyUpstreamBodyMetadata(req, info) + // Do NOT wrap requestBody in a GetBody closure here: returning the same + // (already consumed) reader would make any transport-level retry silently + // replay an empty body. http.NewRequest already derives a correct, + // snapshot-based GetBody for *bytes.Reader/Buffer/strings.Reader bodies + // (which most task adaptors pass in); for type-erased readers, + // ApplyUpstreamBodyMetadata wires a replayable body when one is available. + // Otherwise GetBody stays nil so the transport fails the retry instead of + // sending a corrupted request. err = a.BuildRequestHeader(c, req, info) if err != nil { diff --git a/relay/channel/api_request_getbody_test.go b/relay/channel/api_request_getbody_test.go new file mode 100644 index 000000000000..847468e1b57a --- /dev/null +++ b/relay/channel/api_request_getbody_test.go @@ -0,0 +1,603 @@ +package channel + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" + "golang.org/x/net/http2/hpack" +) + +func TestApplyUpstreamGetBody_SetsReplayableGetBody(t *testing.T) { + t.Parallel() + + payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"hi"}]}`) + + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(payload) + require.NoError(t, err) + defer closer.Close() + + // Mirror DoApiRequest: a type-erased io.Reader gives net/http neither + // ContentLength nor GetBody. + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", body) + require.NoError(t, err) + assert.Nil(t, req.GetBody) + assert.Zero(t, req.ContentLength) + + info := &relaycommon.RelayInfo{ + UpstreamRequestBodySize: size, + UpstreamRequestGetBody: getBody, + } + ApplyUpstreamBodyMetadata(req, info) + + assert.EqualValues(t, len(payload), req.ContentLength) + require.NotNil(t, req.GetBody) + + // Drain the primary body as the transport does on the first attempt, then + // make sure GetBody can replay the complete payload repeatedly. + sent, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, payload, sent) + + for i := 0; i < 2; i++ { + rc, err := req.GetBody() + require.NoError(t, err) + replay, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Equal(t, payload, replay, "replay %d must equal the original payload", i+1) + } +} + +func TestApplyUpstreamGetBody_KeepsExistingGetBody(t *testing.T) { + tests := []struct { + name string + body func() io.Reader + }{ + {name: "bytes reader", body: func() io.Reader { return bytes.NewReader([]byte("original")) }}, + {name: "bytes buffer", body: func() io.Reader { return bytes.NewBufferString("original") }}, + {name: "strings reader", body: func() io.Reader { return strings.NewReader("original") }}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", test.body()) + require.NoError(t, err) + require.NotNil(t, req.GetBody, "net/http must derive GetBody for the concrete reader") + + info := &relaycommon.RelayInfo{ + UpstreamRequestBodySize: 99, + UpstreamRequestGetBody: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte("override"))), nil + }, + } + ApplyUpstreamBodyMetadata(req, info) + + rc, err := req.GetBody() + require.NoError(t, err) + got, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Equal(t, "original", string(got), "an already correct GetBody must not be overwritten") + assert.EqualValues(t, len("original"), req.ContentLength, "native content length must not be overwritten") + }) + } +} + +func TestApplyUpstreamGetBody_NoopWithoutReplaySource(t *testing.T) { + t.Parallel() + + storageBody, _, _, closer, err := relaycommon.NewOutboundJSONBody([]byte(`{}`)) + require.NoError(t, err) + defer closer.Close() + + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", storageBody) + require.NoError(t, err) + + applyUpstreamGetBody(req, nil) + assert.Nil(t, req.GetBody) + + applyUpstreamGetBody(req, &relaycommon.RelayInfo{}) + assert.Nil(t, req.GetBody) +} + +func TestApplyUpstreamBodyMetadata_EmptyStorageRemainsReplayable(t *testing.T) { + t.Parallel() + + storage, err := common.CreateBodyStorage(nil) + require.NoError(t, err) + defer storage.Close() + + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", common.ReaderOnly(storage)) + require.NoError(t, err) + ApplyUpstreamBodyMetadata(req, &relaycommon.RelayInfo{ + UpstreamRequestBodySize: storage.Size(), + UpstreamRequestGetBody: storage.NewReader, + }) + + assert.Zero(t, req.ContentLength) + require.NotNil(t, req.GetBody) + rc, err := req.GetBody() + require.NoError(t, err) + replay, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Empty(t, replay) +} + +func TestUpstreamBodyMetadataIsReboundAcrossChannelAttempts(t *testing.T) { + firstPayload := []byte(`{"attempt":"first-with-longer-body"}`) + _, firstSize, firstGetBody, firstCloser, err := relaycommon.NewOutboundJSONBody(firstPayload) + require.NoError(t, err) + + info := &relaycommon.RelayInfo{ + UpstreamRequestBodySize: firstSize, + UpstreamRequestGetBody: firstGetBody, + } + require.NoError(t, firstCloser.Close()) + _, err = firstGetBody() + require.ErrorIs(t, err, common.ErrStorageClosed) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + info.InitChannelMeta(c) + assert.Zero(t, info.UpstreamRequestBodySize) + assert.Nil(t, info.UpstreamRequestGetBody) + + secondPayload := []byte(`{"attempt":"second"}`) + secondStorage, err := common.CreateBodyStorage(secondPayload) + require.NoError(t, err) + defer secondStorage.Close() + info.UpstreamRequestBodySize = secondStorage.Size() + info.UpstreamRequestGetBody = secondStorage.NewReader + + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", common.ReaderOnly(secondStorage)) + require.NoError(t, err) + ApplyUpstreamBodyMetadata(req, info) + + assert.EqualValues(t, len(secondPayload), req.ContentLength) + require.NotNil(t, req.GetBody) + rc, err := req.GetBody() + require.NoError(t, err) + replay, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Equal(t, secondPayload, replay) +} + +// stubTaskAdaptor implements just enough of TaskAdaptor for DoTaskApiRequest. +type stubTaskAdaptor struct { + TaskAdaptor + baseURL string + capturedReq *http.Request +} + +func (s *stubTaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return s.baseURL + "/v1/video/generations", nil +} + +func (s *stubTaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + s.capturedReq = req + return nil +} + +// TestDoTaskApiRequest_KeepsReplayableGetBody guards against reintroducing the +// hand-rolled GetBody override that wrapped the already consumed request +// reader: any transport-level retry would then have silently replayed an empty +// body. net/http derives a correct snapshot-based GetBody from the +// *bytes.Reader bodies the task adaptors pass in, and it must be left intact. +func TestDoTaskApiRequest_KeepsReplayableGetBody(t *testing.T) { + service.InitHttpClient() + + payload := []byte(`{"model":"test-model","prompt":"hello"}`) + + type receivedBody struct { + body []byte + err error + } + receivedCh := make(chan receivedBody, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + receivedCh <- receivedBody{body: body, err: err} + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", bytes.NewReader(payload)) + + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{}, + } + + adaptor := &stubTaskAdaptor{baseURL: server.URL} + resp, err := DoTaskApiRequest(adaptor, ctx, info, bytes.NewReader(payload)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + received := <-receivedCh + require.NoError(t, received.err) + assert.Equal(t, payload, received.body) + + req := adaptor.capturedReq + require.NotNil(t, req) + require.NotNil(t, req.GetBody) + // Even after the request body has been fully written, GetBody must still + // return the complete payload, repeatedly. + for i := 0; i < 2; i++ { + rc, err := req.GetBody() + require.NoError(t, err) + replay, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Equal(t, payload, replay, "replay %d must equal the original payload", i+1) + } +} + +type h2ServerResult struct { + err error + streamCount int + attemptBodies [][]byte +} + +func acceptH2TestConnection(ln net.Listener) (net.Conn, *http2.Framer, error) { + conn, err := ln.Accept() + if err != nil { + return nil, nil, err + } + _ = conn.SetDeadline(time.Now().Add(15 * time.Second)) + + preface := make([]byte, len(http2.ClientPreface)) + if _, err := io.ReadFull(conn, preface); err != nil { + conn.Close() + return nil, nil, fmt.Errorf("read client preface: %w", err) + } + if !bytes.Equal(preface, []byte(http2.ClientPreface)) { + conn.Close() + return nil, nil, fmt.Errorf("unexpected client preface") + } + + framer := http2.NewFramer(conn, conn) + framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil) + if err := framer.WriteSettings(); err != nil { + conn.Close() + return nil, nil, err + } + return conn, framer, nil +} + +func readH2TestRequest(framer *http2.Framer) (uint32, []byte, error) { + var streamID uint32 + var body []byte + for { + frame, err := framer.ReadFrame() + if err != nil { + return 0, nil, fmt.Errorf("read frame: %w", err) + } + switch f := frame.(type) { + case *http2.SettingsFrame: + if !f.IsAck() { + if err := framer.WriteSettingsAck(); err != nil { + return 0, nil, err + } + } + case *http2.MetaHeadersFrame: + streamID = f.Header().StreamID + if f.StreamEnded() { + return streamID, body, nil + } + case *http2.DataFrame: + if streamID == 0 { + streamID = f.Header().StreamID + } + if f.Header().StreamID != streamID { + continue + } + body = append(body, f.Data()...) + if f.StreamEnded() { + return streamID, body, nil + } + } + } +} + +func writeH2TestResponse(framer *http2.Framer, streamID uint32) error { + var hpackBuf bytes.Buffer + henc := hpack.NewEncoder(&hpackBuf) + if err := henc.WriteField(hpack.HeaderField{Name: ":status", Value: "200"}); err != nil { + return err + } + if err := framer.WriteHeaders(http2.HeadersFrameParam{ + StreamID: streamID, + BlockFragment: hpackBuf.Bytes(), + EndHeaders: true, + }); err != nil { + return err + } + return framer.WriteData(streamID, true, []byte(`{}`)) +} + +func awaitH2ServerResult(t *testing.T, resultCh <-chan h2ServerResult) h2ServerResult { + t.Helper() + select { + case result := <-resultCh: + return result + case <-time.After(20 * time.Second): + t.Fatal("timed out waiting for HTTP/2 test server") + return h2ServerResult{} + } +} + +// runResetOnFirstStreamServer speaks just enough raw HTTP/2 to emulate an +// upstream that accepts the first request, waits until the request body has +// been fully written, and then resets the stream with REFUSED_STREAM (the +// retry-safe reset some proxy/CDN-fronted upstreams send under load or during +// graceful shutdown, see RFC 9113 section 8.7). When expectRetry is true it +// serves the retried stream a 200 response; otherwise it stops after the reset. +func runResetOnFirstStreamServer(ln net.Listener, expectRetry bool) <-chan h2ServerResult { + resCh := make(chan h2ServerResult, 1) + go func() { + res := h2ServerResult{} + defer func() { resCh <- res }() + + conn, framer, err := acceptH2TestConnection(ln) + if err != nil { + res.err = err + return + } + defer conn.Close() + + attempts: + for attempt := 0; ; attempt++ { + streamID, body, err := readH2TestRequest(framer) + if err != nil { + res.err = err + return + } + res.streamCount++ + res.attemptBodies = append(res.attemptBodies, body) + + if attempt == 0 { + if err := framer.WriteRSTStream(streamID, http2.ErrCodeRefusedStream); err != nil { + res.err = err + return + } + if !expectRetry { + break attempts + } + continue + } + + if err := writeH2TestResponse(framer, streamID); err != nil { + res.err = err + } + return + } + }() + return resCh +} + +func runGoAwayAfterFirstRequestServer(ln net.Listener) <-chan h2ServerResult { + resCh := make(chan h2ServerResult, 1) + go func() { + res := h2ServerResult{} + defer func() { resCh <- res }() + + for attempt := 0; attempt < 2; attempt++ { + conn, framer, err := acceptH2TestConnection(ln) + if err != nil { + res.err = err + return + } + streamID, body, err := readH2TestRequest(framer) + if err != nil { + conn.Close() + res.err = err + return + } + res.streamCount++ + res.attemptBodies = append(res.attemptBodies, body) + + if attempt == 0 { + err = framer.WriteGoAway(0, http2.ErrCodeNo, nil) + conn.Close() + if err != nil { + res.err = err + return + } + continue + } + + err = writeH2TestResponse(framer, streamID) + conn.Close() + if err != nil { + res.err = err + } + return + } + }() + return resCh +} + +func newH2PriorKnowledgeClient(ln net.Listener) (*http.Client, *http2.Transport) { + transport := &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, network, ln.Addr().String()) + }, + } + return &http.Client{Transport: transport, Timeout: 15 * time.Second}, transport +} + +func newPassThroughBody(t *testing.T, payload []byte) (io.Reader, *relaycommon.RelayInfo, common.BodyStorage) { + t.Helper() + storage, err := common.CreateBodyStorage(payload) + require.NoError(t, err) + return common.ReaderOnly(storage), &relaycommon.RelayInfo{ + UpstreamRequestBodySize: storage.Size(), + UpstreamRequestGetBody: storage.NewReader, + }, storage +} + +// TestUpstreamGetBody_HTTP2RetryAfterUpstreamStreamReset exercises the actual +// failure this change fixes: an HTTP/2 upstream resets the stream with a +// retryable error after the request body has been written. With GetBody wired +// up the transport must transparently retry, and the retried request must +// carry the complete body. +func TestUpstreamGetBody_HTTP2RetryAfterUpstreamStreamReset(t *testing.T) { + payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"retry me"}]}`) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + resCh := runResetOnFirstStreamServer(ln, true) + + client, transport := newH2PriorKnowledgeClient(ln) + defer transport.CloseIdleConnections() + + // Build the upstream request exactly the way DoApiRequest does: a + // type-erased BodyStorage reader plus the applyUpstream* helpers. + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(payload) + require.NoError(t, err) + defer closer.Close() + + req, err := http.NewRequest(http.MethodPost, "http://upstream.test/v1/chat/completions", body) + require.NoError(t, err) + info := &relaycommon.RelayInfo{ + UpstreamRequestBodySize: size, + UpstreamRequestGetBody: getBody, + } + ApplyUpstreamBodyMetadata(req, info) + require.NotNil(t, req.GetBody) + + resp, err := client.Do(req) + require.NoError(t, err, "the transport must transparently retry after RST_STREAM") + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + srv := awaitH2ServerResult(t, resCh) + require.NoError(t, srv.err) + assert.Equal(t, 2, srv.streamCount, "the request must have been attempted twice") + require.Len(t, srv.attemptBodies, 2) + assert.Equal(t, payload, srv.attemptBodies[0], "first attempt must carry the full body") + assert.Equal(t, payload, srv.attemptBodies[1], "the retried request must carry the complete body") +} + +func TestUpstreamGetBody_HTTP2RetryAfterUpstreamStreamReset_PassThrough(t *testing.T) { + payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"pass through"}]}`) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + resCh := runResetOnFirstStreamServer(ln, true) + + client, transport := newH2PriorKnowledgeClient(ln) + defer transport.CloseIdleConnections() + + body, info, storage := newPassThroughBody(t, payload) + defer storage.Close() + req, err := http.NewRequest(http.MethodPost, "http://upstream.test/v1/chat/completions", body) + require.NoError(t, err) + ApplyUpstreamBodyMetadata(req, info) + require.NotNil(t, req.GetBody) + assert.EqualValues(t, len(payload), req.ContentLength) + + resp, err := client.Do(req) + require.NoError(t, err, "the transport must transparently retry a pass-through body after RST_STREAM") + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + srv := awaitH2ServerResult(t, resCh) + require.NoError(t, srv.err) + assert.Equal(t, 2, srv.streamCount) + require.Len(t, srv.attemptBodies, 2) + assert.Equal(t, payload, srv.attemptBodies[0]) + assert.Equal(t, payload, srv.attemptBodies[1]) +} + +func TestUpstreamGetBody_HTTP2RetryAfterGracefulGoAway_PassThrough(t *testing.T) { + payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"go away"}]}`) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + resCh := runGoAwayAfterFirstRequestServer(ln) + + client, transport := newH2PriorKnowledgeClient(ln) + defer transport.CloseIdleConnections() + + body, info, storage := newPassThroughBody(t, payload) + defer storage.Close() + req, err := http.NewRequest(http.MethodPost, "http://upstream.test/v1/chat/completions", body) + require.NoError(t, err) + ApplyUpstreamBodyMetadata(req, info) + require.NotNil(t, req.GetBody) + + resp, err := client.Do(req) + require.NoError(t, err, "the transport must retry on a new connection after graceful GOAWAY") + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + srv := awaitH2ServerResult(t, resCh) + require.NoError(t, srv.err) + assert.Equal(t, 2, srv.streamCount) + require.Len(t, srv.attemptBodies, 2) + assert.Equal(t, payload, srv.attemptBodies[0]) + assert.Equal(t, payload, srv.attemptBodies[1]) +} + +// TestUpstreamGetBody_HTTP2CannotRetryWithoutGetBody documents the pre-fix +// behavior: without GetBody the transport cannot safely retry once the body +// has been written, and the whole relay request fails. +func TestUpstreamGetBody_HTTP2CannotRetryWithoutGetBody(t *testing.T) { + payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"retry me"}]}`) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + resCh := runResetOnFirstStreamServer(ln, false) + + client, transport := newH2PriorKnowledgeClient(ln) + defer transport.CloseIdleConnections() + + body, size, _, closer, err := relaycommon.NewOutboundJSONBody(payload) + require.NoError(t, err) + defer closer.Close() + + req, err := http.NewRequest(http.MethodPost, "http://upstream.test/v1/chat/completions", body) + require.NoError(t, err) + applyUpstreamContentLength(req, &relaycommon.RelayInfo{UpstreamRequestBodySize: size}) + assert.Nil(t, req.GetBody) + + resp, err := client.Do(req) //nolint:bodyclose // Do fails, no body to close + require.Error(t, err) + assert.Nil(t, resp) + require.ErrorContains(t, err, "cannot retry err") + require.ErrorContains(t, err, "Request.Body was written") + + srv := awaitH2ServerResult(t, resCh) + require.NoError(t, srv.err) + assert.Equal(t, 1, srv.streamCount) + require.Len(t, srv.attemptBodies, 1) + assert.Equal(t, payload, srv.attemptBodies[0]) +} diff --git a/relay/channel/api_request_redirect_test.go b/relay/channel/api_request_redirect_test.go new file mode 100644 index 000000000000..e24685ad56c4 --- /dev/null +++ b/relay/channel/api_request_redirect_test.go @@ -0,0 +1,88 @@ +package channel + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "reflect" + "sync/atomic" + "testing" + + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDoRequestReturnsUpstreamRedirectWithoutFollowing(t *testing.T) { + service.InitHttpClient() + gin.SetMode(gin.TestMode) + sharedClient := service.GetHttpClient() + require.NotNil(t, sharedClient) + require.NotNil(t, sharedClient.CheckRedirect) + originalRedirectPolicy := reflect.ValueOf(sharedClient.CheckRedirect).Pointer() + + var targetRequests atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetRequests.Add(1) + w.WriteHeader(http.StatusTeapot) + })) + defer target.Close() + + const responseBody = "redirect response" + tests := []int{ + http.StatusMovedPermanently, + http.StatusFound, + http.StatusSeeOther, + http.StatusTemporaryRedirect, + http.StatusPermanentRedirect, + } + + for _, statusCode := range tests { + t.Run(http.StatusText(statusCode), func(t *testing.T) { + targetRequests.Store(0) + var sourceRequests atomic.Int32 + type sourceResult struct { + body []byte + err error + } + sourceResultCh := make(chan sourceResult, 1) + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sourceRequests.Add(1) + body, err := io.ReadAll(r.Body) + sourceResultCh <- sourceResult{body: body, err: err} + w.Header().Set("Location", target.URL+"/redirect-target") + w.WriteHeader(statusCode) + _, _ = io.WriteString(w, responseBody) + })) + defer source.Close() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/relay", nil) + + req, err := http.NewRequest(http.MethodPost, source.URL, bytes.NewReader([]byte("request body"))) + require.NoError(t, err) + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} + + resp, err := doRequest(ctx, req, info) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + gotSource := <-sourceResultCh + require.NoError(t, gotSource.err) + + assert.Equal(t, statusCode, resp.StatusCode) + assert.Equal(t, target.URL+"/redirect-target", resp.Header.Get("Location")) + assert.Equal(t, responseBody, string(body)) + assert.Equal(t, []byte("request body"), gotSource.body) + assert.EqualValues(t, 1, sourceRequests.Load()) + assert.Zero(t, targetRequests.Load()) + }) + } + + assert.Equal(t, originalRedirectPolicy, reflect.ValueOf(sharedClient.CheckRedirect).Pointer(), "the cached client must not be mutated") +} diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go index 658d9236420d..d2f9c389e41d 100644 --- a/relay/channel/jimeng/adaptor.go +++ b/relay/channel/jimeng/adaptor.go @@ -112,6 +112,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } + channel.ApplyUpstreamBodyMetadata(req, info) err = Sign(c, req, info.ApiKey) if err != nil { return nil, fmt.Errorf("setup request header failed: %w", err) diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go index e9029aa20d46..59a7a258b2db 100644 --- a/relay/channel/task/sora/adaptor.go +++ b/relay/channel/task/sora/adaptor.go @@ -216,6 +216,8 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn return &buf, nil } + info.UpstreamRequestBodySize = storage.Size() + info.UpstreamRequestGetBody = storage.NewReader return common.ReaderOnly(storage), nil } diff --git a/relay/channel/task/sora/adaptor_test.go b/relay/channel/task/sora/adaptor_test.go new file mode 100644 index 000000000000..ef514952401c --- /dev/null +++ b/relay/channel/task/sora/adaptor_test.go @@ -0,0 +1,40 @@ +package sora + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSoraBuildRequestBodyBindsReplayMetadataForPassThrough(t *testing.T) { + payload := []byte("opaque-sora-request-body") + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", bytes.NewReader(payload)) + c.Request.Header.Set("Content-Type", "application/octet-stream") + defer common.CleanupBodyStorage(c) + + info := &relaycommon.RelayInfo{} + body, err := (&TaskAdaptor{}).BuildRequestBody(c, info) + require.NoError(t, err) + + sent, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, payload, sent) + assert.EqualValues(t, len(payload), info.UpstreamRequestBodySize) + require.NotNil(t, info.UpstreamRequestGetBody) + + replayBody, err := info.UpstreamRequestGetBody() + require.NoError(t, err) + replay, err := io.ReadAll(replayBody) + require.NoError(t, err) + require.NoError(t, replayBody.Close()) + assert.Equal(t, payload, replay) +} diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index ccc7fb281220..660cc8891af8 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -128,13 +128,14 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody var requestBody io.Reader = body var httpResp *http.Response diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 63bc11f5889c..bcbe99089f9c 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -160,6 +160,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } info.UpstreamRequestBodySize = storage.Size() + info.UpstreamRequestGetBody = storage.NewReader requestBody = common.ReaderOnly(storage) } else { convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request) @@ -187,13 +188,14 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } logger.LogDebug(c, "requestBody: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody requestBody = body } diff --git a/relay/common/outbound_body.go b/relay/common/outbound_body.go index 94ef8dde1da6..0316cf74d11c 100644 --- a/relay/common/outbound_body.go +++ b/relay/common/outbound_body.go @@ -22,10 +22,22 @@ import ( // transport from prematurely closing the underlying BodyStorage. The returned // size is meant to be propagated to http.Request.ContentLength because the // type-erased io.Reader prevents net/http from auto-detecting it. -func NewOutboundJSONBody(data []byte) (body io.Reader, size int64, closer io.Closer, err error) { +// +// The returned getBody hands out a new, independent reader over the full body +// on every call, per the http.Request.GetBody contract of returning a fresh +// copy of the body. It is meant to be propagated to http.Request.GetBody +// (which net/http likewise cannot derive from a type-erased io.Reader) so the +// HTTP/2 transport can transparently retry the request when the upstream +// resets the stream after the body was already written ("http2: Transport: +// cannot retry err ... after Request.Body was written"). Each reader has its +// own cursor — in memory mode a fresh bytes.Reader over the shared immutable +// backing array, in disk mode a separate file descriptor — so replays never +// share seek state with the primary body or with each other, and closing a +// replayed reader never releases the underlying storage. +func NewOutboundJSONBody(data []byte) (body io.Reader, size int64, getBody func() (io.ReadCloser, error), closer io.Closer, err error) { storage, err := common.CreateBodyStorage(data) if err != nil { - return nil, 0, nil, err + return nil, 0, nil, nil, err } - return common.ReaderOnly(storage), storage.Size(), storage, nil + return common.ReaderOnly(storage), storage.Size(), storage.NewReader, storage, nil } diff --git a/relay/common/outbound_body_test.go b/relay/common/outbound_body_test.go new file mode 100644 index 000000000000..23e2ff959187 --- /dev/null +++ b/relay/common/outbound_body_test.go @@ -0,0 +1,163 @@ +package common + +import ( + "io" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOutboundJSONBody_GetBodyReplaysFullBody(t *testing.T) { + t.Parallel() + + payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"hello"}]}`) + + body, size, getBody, closer, err := NewOutboundJSONBody(payload) + require.NoError(t, err) + defer closer.Close() + + assert.EqualValues(t, len(payload), size) + require.NotNil(t, getBody) + + // Consume the primary body, as the HTTP transport does on the first attempt. + first, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, payload, first) + + // GetBody must hand out the complete body again — and repeatedly, since the + // transport may need more than one retry. + for i := 0; i < 2; i++ { + rc, err := getBody() + require.NoError(t, err) + replay, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Equal(t, payload, replay, "replay %d must equal the original payload", i+1) + } +} + +func TestNewOutboundJSONBody_GetBodyAfterPartialRead(t *testing.T) { + t.Parallel() + + payload := []byte(`{"model":"test-model","input":"0123456789"}`) + + body, _, getBody, closer, err := NewOutboundJSONBody(payload) + require.NoError(t, err) + defer closer.Close() + + // Simulate an aborted first attempt that only wrote part of the body. + partial := make([]byte, 10) + _, err = io.ReadFull(body, partial) + require.NoError(t, err) + + rc, err := getBody() + require.NoError(t, err) + replay, err := io.ReadAll(rc) + require.NoError(t, err) + assert.Equal(t, payload, replay) + + // Closing the replayed body must not close the underlying storage: the + // handler owns the storage lifetime via the returned closer. + require.NoError(t, rc.Close()) + rc2, err := getBody() + require.NoError(t, err) + replay2, err := io.ReadAll(rc2) + require.NoError(t, err) + require.NoError(t, rc2.Close()) + assert.Equal(t, payload, replay2) +} + +// assertIndependentReplayReaders proves that readers handed out by getBody own +// independent cursors, per the http.Request.GetBody contract of returning a +// new copy of the body: interleaved reads across two replay readers and the +// primary body each observe exactly their own byte stream. +func assertIndependentReplayReaders(t *testing.T, payload []byte, body io.Reader, getBody func() (io.ReadCloser, error)) { + t.Helper() + + half := len(payload) / 2 + + // Partially drain the primary body first, as if attempt N's body write + // were still in flight when the transport builds attempt N+1 via GetBody. + primaryHead := make([]byte, half) + _, err := io.ReadFull(body, primaryHead) + require.NoError(t, err) + assert.Equal(t, payload[:half], primaryHead) + + // Interleave two replay readers: A reads half, B reads everything, then A + // reads the rest. + a, err := getBody() + require.NoError(t, err) + b, err := getBody() + require.NoError(t, err) + + aHead := make([]byte, half) + _, err = io.ReadFull(a, aHead) + require.NoError(t, err) + assert.Equal(t, payload[:half], aHead) + + bAll, err := io.ReadAll(b) + require.NoError(t, err) + require.NoError(t, b.Close()) + assert.Equal(t, payload, bAll, "reader B must see the complete body even while A is mid-read") + + aRest, err := io.ReadAll(a) + require.NoError(t, err) + require.NoError(t, a.Close()) + assert.Equal(t, payload[half:], aRest, "reader A must resume from its own cursor, unaffected by B") + + // The replays must not have disturbed the primary body's cursor either. + primaryRest, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, payload[half:], primaryRest, "the primary body must be unaffected by replay readers") +} + +func TestNewOutboundJSONBody_GetBodyReadersAreIndependent(t *testing.T) { + t.Parallel() + + payload := []byte(`{"model":"test-model","input":"abcdefghijklmnopqrstuvwxyz"}`) + + body, _, getBody, closer, err := NewOutboundJSONBody(payload) + require.NoError(t, err) + defer closer.Close() + + assertIndependentReplayReaders(t, payload, body, getBody) + + // Once the handler releases the storage, GetBody must fail loudly instead + // of replaying stale data. + require.NoError(t, closer.Close()) + _, err = getBody() + require.ErrorIs(t, err, common.ErrStorageClosed) +} + +// TestNewOutboundJSONBody_GetBodyReadersAreIndependent_DiskStorage runs the +// same independence assertions against the disk-backed storage. Deliberately +// not parallel: it temporarily lowers the global disk-cache threshold so the +// payload takes the diskStorage path. +func TestNewOutboundJSONBody_GetBodyReadersAreIndependent_DiskStorage(t *testing.T) { + prev := common.GetDiskCacheConfig() + common.SetDiskCacheConfig(common.DiskCacheConfig{ + Enabled: true, + ThresholdMB: 0, + MaxSizeMB: 64, + Path: t.TempDir(), + }) + defer common.SetDiskCacheConfig(prev) + + payload := []byte(`{"model":"test-model","input":"abcdefghijklmnopqrstuvwxyz"}`) + + body, _, getBody, closer, err := NewOutboundJSONBody(payload) + require.NoError(t, err) + defer closer.Close() + + storage, ok := closer.(common.BodyStorage) + require.True(t, ok) + assert.True(t, storage.IsDisk(), "the payload must have taken the diskStorage path") + + assertIndependentReplayReaders(t, payload, body, getBody) + + require.NoError(t, closer.Close()) + _, err = getBody() + require.ErrorIs(t, err, common.ErrStorageClosed) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 45ae30bf9fe8..52c741158c3e 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "strconv" "strings" "time" @@ -156,6 +157,16 @@ type RelayInfo struct { // *bytes.Reader/Buffer/strings.Reader). 0 means "let net/http decide". UpstreamRequestBodySize int64 + // UpstreamRequestGetBody returns a fresh reader over the full marshaled + // upstream request body. It is set alongside UpstreamRequestBodySize when + // the body is wrapped in a BodyStorage (see relay/common/outbound_body.go), + // so that DoApiRequest can populate http.Request.GetBody manually (net/http + // only auto-populates it for *bytes.Reader/Buffer/strings.Reader). Without + // GetBody the HTTP/2 transport cannot transparently retry a request whose + // stream was reset by the upstream after the body was already written. + // nil means "no safe replay available". + UpstreamRequestGetBody func() (io.ReadCloser, error) + PriceData hosttypes.PriceData // QuotaClamp is set (non-nil) when a quota conversion saturated at the @@ -193,6 +204,12 @@ type RelayInfo struct { } func (info *RelayInfo) InitChannelMeta(c *gin.Context) { + // RelayInfo is reused across channel attempts. Body metadata belongs to the + // current attempt and may reference storage that its handler has closed, so + // discard it before the next channel binds its outbound body. + info.UpstreamRequestBodySize = 0 + info.UpstreamRequestGetBody = nil + channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride) diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go index 9e58f3f92d8b..edf5fdbf5546 100644 --- a/relay/common/relay_info_test.go +++ b/relay/common/relay_info_test.go @@ -1,14 +1,32 @@ package common import ( + "io" + "net/http/httptest" "testing" "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta" "github.com/QuantumNous/new-api/relaykit/types" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestInitChannelMetaClearsUpstreamBodyMetadata(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &RelayInfo{ + UpstreamRequestBodySize: 37, + UpstreamRequestGetBody: func() (io.ReadCloser, error) { + return nil, nil + }, + } + + info.InitChannelMeta(c) + + assert.Zero(t, info.UpstreamRequestBodySize) + assert.Nil(t, info.UpstreamRequestGetBody) +} + func TestRelayInfoGetFinalRequestRelayFormatPrefersExplicitFinal(t *testing.T) { info := &RelayInfo{ RelayFormat: types.RelayFormatOpenAI, diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index b1e25e036f8d..24f355f9d4f5 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -104,6 +104,8 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types logger.LogDebug(c, "requestBody: %s", debugBytes) } } + info.UpstreamRequestBodySize = storage.Size() + info.UpstreamRequestGetBody = storage.NewReader requestBody = common.ReaderOnly(storage) } else { convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request) @@ -175,13 +177,14 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types logger.LogDebug(c, "text request body: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody requestBody = body } diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index 3be543bd91a1..d3b617b656e4 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -58,13 +58,14 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } logger.LogDebug(c, "converted embedding request body: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody var requestBody io.Reader = body statusCodeMappingStr := c.GetString("status_code_mapping") resp, err := adaptor.DoRequest(c, info, requestBody) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 786feb8ba345..38ac0d87de45 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -141,6 +141,8 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } + info.UpstreamRequestBodySize = storage.Size() + info.UpstreamRequestGetBody = storage.NewReader requestBody = common.ReaderOnly(storage) } else { // 使用 ConvertGeminiRequest 转换请求格式 @@ -164,13 +166,14 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ logger.LogDebug(c, "Gemini request body: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody requestBody = body } @@ -269,13 +272,14 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI } } logger.LogDebug(c, "Gemini embedding request body: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody requestBody = body resp, err := adaptor.DoRequest(c, info, requestBody) diff --git a/relay/image_handler.go b/relay/image_handler.go index f970a43e8e20..2d99a63c60d7 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -51,6 +51,8 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } + info.UpstreamRequestBodySize = storage.Size() + info.UpstreamRequestGetBody = storage.NewReader requestBody = common.ReaderOnly(storage) } else { convertedRequest, err := adaptor.ConvertImageRequest(c, info, *request) @@ -77,13 +79,14 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type } logger.LogDebug(c, "image request body: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody requestBody = body } } diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 769feb227415..e2546dda9640 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -47,6 +47,8 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } + info.UpstreamRequestBodySize = storage.Size() + info.UpstreamRequestGetBody = storage.NewReader requestBody = common.ReaderOnly(storage) } else { convertedRequest, err := adaptor.ConvertRerankRequest(c, info.RelayMode, *request) @@ -68,13 +70,14 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } logger.LogDebug(c, "Rerank request body: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody requestBody = body } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index c736c6a546b5..2531b3a63bd1 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -82,6 +82,8 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry()) } + info.UpstreamRequestBodySize = storage.Size() + info.UpstreamRequestGetBody = storage.NewReader requestBody = common.ReaderOnly(storage) } else { convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *request) @@ -109,13 +111,14 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } logger.LogDebug(c, "requestBody: %s", jsonData) - body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil info.UpstreamRequestBodySize = size + info.UpstreamRequestGetBody = getBody requestBody = body } From ea4f021012cddc52126123ab4ed8ced3df260b85 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 6 Aug 2026 17:33:10 +0800 Subject: [PATCH 02/18] refactor(relay): move replay metadata onto request bodies --- common/body_storage.go | 33 ++++- common/body_storage_test.go | 37 +++++ relay/alpha_search_handler.go | 4 +- relay/channel/api_request.go | 61 +++----- relay/channel/api_request_getbody_test.go | 169 ++++++++++------------ relay/channel/jimeng/adaptor.go | 2 +- relay/channel/task/sora/adaptor.go | 4 +- relay/channel/task/sora/adaptor_test.go | 9 +- relay/chat_completions_via_responses.go | 4 +- relay/claude_handler.go | 8 +- relay/common/outbound_body.go | 26 +--- relay/common/outbound_body_test.go | 31 ++-- relay/common/relay_info.go | 24 --- relay/common/relay_info_test.go | 18 --- relay/compatible_handler.go | 8 +- relay/embedding_handler.go | 4 +- relay/gemini_handler.go | 12 +- relay/image_handler.go | 8 +- relay/rerank_handler.go | 8 +- relay/responses_handler.go | 8 +- 20 files changed, 211 insertions(+), 267 deletions(-) create mode 100644 common/body_storage_test.go diff --git a/common/body_storage.go b/common/body_storage.go index 0c4a849dc969..515d749e10c9 100644 --- a/common/body_storage.go +++ b/common/body_storage.go @@ -29,6 +29,14 @@ type BodyStorage interface { NewReader() (io.ReadCloser, error) } +// ReplayableBody is an outbound request body that can report its byte size and +// create independent readers for transport-level retries. +type ReplayableBody interface { + io.Reader + Size() int64 + NewReader() (io.ReadCloser, error) +} + // ErrStorageClosed 存储已关闭错误 var ErrStorageClosed = fmt.Errorf("body storage is closed") @@ -339,10 +347,27 @@ func CreateBodyStorageFromReader(reader io.Reader, contentLength int64, maxBytes return storage, nil } -// ReaderOnly wraps an io.Reader to hide io.Closer, preventing http.NewRequest -// from type-asserting io.ReadCloser and closing the underlying BodyStorage. -func ReaderOnly(r io.Reader) io.Reader { - return struct{ io.Reader }{r} +type replayableBodyReader struct { + storage BodyStorage +} + +func (r replayableBodyReader) Read(p []byte) (int, error) { + return r.storage.Read(p) +} + +func (r replayableBodyReader) Size() int64 { + return r.storage.Size() +} + +func (r replayableBodyReader) NewReader() (io.ReadCloser, error) { + return r.storage.NewReader() +} + +// NewReplayableBodyReader exposes the replay capabilities of storage without +// exposing io.Closer. This keeps ownership of the storage lifecycle with the +// caller instead of allowing net/http to close it as the request body. +func NewReplayableBodyReader(storage BodyStorage) ReplayableBody { + return replayableBodyReader{storage: storage} } // CleanupOldCacheFiles 清理旧的缓存文件(用于启动时清理残留) diff --git a/common/body_storage_test.go b/common/body_storage_test.go new file mode 100644 index 000000000000..c3f877a60ff4 --- /dev/null +++ b/common/body_storage_test.go @@ -0,0 +1,37 @@ +package common + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewReplayableBodyReaderKeepsStorageLifecycleWithCaller(t *testing.T) { + payload := []byte(`{"model":"test-model","input":"hello"}`) + storage, err := CreateBodyStorage(payload) + require.NoError(t, err) + defer storage.Close() + + body := NewReplayableBodyReader(storage) + assert.EqualValues(t, len(payload), body.Size()) + _, exposesCloser := any(body).(io.Closer) + assert.False(t, exposesCloser, "the request body must not expose the storage closer") + + req, err := http.NewRequest(http.MethodPost, "https://example.com", body) + require.NoError(t, err) + require.NoError(t, req.Body.Close()) + + replayBody, err := body.NewReader() + require.NoError(t, err, "closing the HTTP request body must not close the storage") + replay, err := io.ReadAll(replayBody) + require.NoError(t, err) + require.NoError(t, replayBody.Close()) + assert.Equal(t, payload, replay) + + require.NoError(t, storage.Close()) + _, err = body.NewReader() + require.ErrorIs(t, err, ErrStorageClosed) +} diff --git a/relay/alpha_search_handler.go b/relay/alpha_search_handler.go index c66cb1deb5b1..246eebe864ee 100644 --- a/relay/alpha_search_handler.go +++ b/relay/alpha_search_handler.go @@ -62,13 +62,11 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError } logger.LogDebug(c, "requestBody: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody adaptor := GetAdaptor(info.ApiType) if adaptor == nil { diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 2ed9f69f1222..48241b14a5e9 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -25,50 +25,29 @@ import ( "github.com/gorilla/websocket" ) -// applyUpstreamContentLength populates req.ContentLength when the upstream -// body is wrapped in a BodyStorage (see relay/common/outbound_body.go). -// -// net/http.NewRequest only auto-detects ContentLength for *bytes.Reader, -// *bytes.Buffer and *strings.Reader. When the body is a type-erased io.Reader -// (which is the case for ReaderOnly(BodyStorage)), the Content-Length header -// would otherwise be omitted, forcing chunked transfer encoding and breaking -// some upstreams that require an explicit Content-Length. -func applyUpstreamContentLength(req *http.Request, info *common.RelayInfo) { - if info == nil { +// ApplyUpstreamBodyMetadata restores metadata that net/http cannot infer from +// a ReplayableBody. Callers must pass the original body because NewRequest +// hides its dynamic type behind req.Body's io.ReadCloser wrapper. +func ApplyUpstreamBodyMetadata(req *http.Request, body io.Reader) { + replayable, ok := body.(common2.ReplayableBody) + if !ok { return } - if info.UpstreamRequestBodySize > 0 && req.ContentLength <= 0 { - req.ContentLength = info.UpstreamRequestBodySize - } -} -// applyUpstreamGetBody populates req.GetBody when the upstream body is wrapped -// in a BodyStorage (see relay/common/outbound_body.go). -// -// net/http.NewRequest only auto-populates GetBody for *bytes.Reader, -// *bytes.Buffer and *strings.Reader. When the body is a type-erased io.Reader -// (which is the case for ReaderOnly(BodyStorage)), GetBody would otherwise stay -// nil, and the HTTP/2 transport cannot transparently retry the request once the -// upstream resets the stream after the body was already written; the request -// then fails with "http2: Transport: cannot retry err ... after Request.Body -// was written; define Request.GetBody to avoid this error". -func applyUpstreamGetBody(req *http.Request, info *common.RelayInfo) { - if info == nil || info.UpstreamRequestGetBody == nil { - return + // BodyStorage structurally satisfies ReplayableBody, but it also exposes + // io.Closer. If a caller passes the storage directly instead of using + // NewReplayableBodyReader, hide Close before the transport takes ownership + // of req.Body so the shared replay source remains available to GetBody. + if _, rawStorage := body.(common2.BodyStorage); rawStorage { + req.Body = io.NopCloser(body) } + + req.ContentLength = replayable.Size() if req.GetBody == nil { - req.GetBody = info.UpstreamRequestGetBody + req.GetBody = replayable.NewReader } } -// ApplyUpstreamBodyMetadata restores metadata that net/http cannot infer when -// a BodyStorage is exposed through a type-erased reader. Provider adaptors -// that construct requests directly should call this before sending them. -func ApplyUpstreamBodyMetadata(req *http.Request, info *common.RelayInfo) { - applyUpstreamContentLength(req, info) - applyUpstreamGetBody(req, info) -} - func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Header) { if info.RelayMode == constant.RelayModeAudioTranscription || info.RelayMode == constant.RelayModeAudioTranslation { // multipart/form-data @@ -341,7 +320,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - ApplyUpstreamBodyMetadata(req, info) + ApplyUpstreamBodyMetadata(req, requestBody) headers := req.Header err = a.SetupRequestHeader(c, &headers, info) if err != nil { @@ -371,7 +350,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - ApplyUpstreamBodyMetadata(req, info) + ApplyUpstreamBodyMetadata(req, requestBody) // set form data req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) headers := req.Header @@ -588,13 +567,13 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, req if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - ApplyUpstreamBodyMetadata(req, info) + ApplyUpstreamBodyMetadata(req, requestBody) // Do NOT wrap requestBody in a GetBody closure here: returning the same // (already consumed) reader would make any transport-level retry silently // replay an empty body. http.NewRequest already derives a correct, // snapshot-based GetBody for *bytes.Reader/Buffer/strings.Reader bodies - // (which most task adaptors pass in); for type-erased readers, - // ApplyUpstreamBodyMetadata wires a replayable body when one is available. + // (which most task adaptors pass in); ApplyUpstreamBodyMetadata wires the + // same contract for bodies that explicitly implement ReplayableBody. // Otherwise GetBody stays nil so the transport fails the retry instead of // sending a corrupted request. diff --git a/relay/channel/api_request_getbody_test.go b/relay/channel/api_request_getbody_test.go index 847468e1b57a..9a2de73337cd 100644 --- a/relay/channel/api_request_getbody_test.go +++ b/relay/channel/api_request_getbody_test.go @@ -23,27 +23,29 @@ import ( "golang.org/x/net/http2/hpack" ) -func TestApplyUpstreamGetBody_SetsReplayableGetBody(t *testing.T) { +func TestApplyUpstreamBodyMetadataSetsReplayableMetadata(t *testing.T) { t.Parallel() payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"hi"}]}`) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(payload) + body, closer, err := relaycommon.NewOutboundJSONBody(payload) require.NoError(t, err) defer closer.Close() - // Mirror DoApiRequest: a type-erased io.Reader gives net/http neither - // ContentLength nor GetBody. + // NewRequest hides the body's dynamic type behind req.Body, so metadata + // extraction must use the original body passed to NewRequest. req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", body) require.NoError(t, err) assert.Nil(t, req.GetBody) assert.Zero(t, req.ContentLength) + _, requestBodyIsReplayable := req.Body.(common.ReplayableBody) + assert.False(t, requestBodyIsReplayable) - info := &relaycommon.RelayInfo{ - UpstreamRequestBodySize: size, - UpstreamRequestGetBody: getBody, - } - ApplyUpstreamBodyMetadata(req, info) + ApplyUpstreamBodyMetadata(req, req.Body) + assert.Nil(t, req.GetBody) + assert.Zero(t, req.ContentLength) + + ApplyUpstreamBodyMetadata(req, body) assert.EqualValues(t, len(payload), req.ContentLength) require.NotNil(t, req.GetBody) @@ -64,7 +66,40 @@ func TestApplyUpstreamGetBody_SetsReplayableGetBody(t *testing.T) { } } -func TestApplyUpstreamGetBody_KeepsExistingGetBody(t *testing.T) { +func TestApplyUpstreamBodyMetadataHidesRawBodyStorageCloser(t *testing.T) { + t.Parallel() + + payload := []byte(`{"model":"test-model","input":"raw storage"}`) + storage, err := common.CreateBodyStorage(payload) + require.NoError(t, err) + defer storage.Close() + + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", storage) + require.NoError(t, err) + _, exposesStorageBeforeApply := req.Body.(common.BodyStorage) + require.True(t, exposesStorageBeforeApply) + + ApplyUpstreamBodyMetadata(req, storage) + + _, exposesStorageAfterApply := req.Body.(common.BodyStorage) + assert.False(t, exposesStorageAfterApply) + assert.EqualValues(t, len(payload), req.ContentLength) + require.NotNil(t, req.GetBody) + + sent, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, payload, sent) + require.NoError(t, req.Body.Close()) + + replayBody, err := req.GetBody() + require.NoError(t, err, "closing the HTTP request body must not close the shared storage") + replay, err := io.ReadAll(replayBody) + require.NoError(t, err) + require.NoError(t, replayBody.Close()) + assert.Equal(t, payload, replay) +} + +func TestApplyUpstreamBodyMetadataKeepsNativeMetadataForNonReplayableBody(t *testing.T) { tests := []struct { name string body func() io.Reader @@ -79,17 +114,12 @@ func TestApplyUpstreamGetBody_KeepsExistingGetBody(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", test.body()) + body := test.body() + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", body) require.NoError(t, err) require.NotNil(t, req.GetBody, "net/http must derive GetBody for the concrete reader") - info := &relaycommon.RelayInfo{ - UpstreamRequestBodySize: 99, - UpstreamRequestGetBody: func() (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader([]byte("override"))), nil - }, - } - ApplyUpstreamBodyMetadata(req, info) + ApplyUpstreamBodyMetadata(req, body) rc, err := req.GetBody() require.NoError(t, err) @@ -102,36 +132,43 @@ func TestApplyUpstreamGetBody_KeepsExistingGetBody(t *testing.T) { } } -func TestApplyUpstreamGetBody_NoopWithoutReplaySource(t *testing.T) { +func TestApplyUpstreamBodyMetadataKeepsExistingGetBody(t *testing.T) { t.Parallel() - storageBody, _, _, closer, err := relaycommon.NewOutboundJSONBody([]byte(`{}`)) + payload := []byte(`{"model":"test-model"}`) + body, closer, err := relaycommon.NewOutboundJSONBody(payload) require.NoError(t, err) defer closer.Close() - req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", storageBody) + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", body) require.NoError(t, err) + req.ContentLength = 99 + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte("existing"))), nil + } - applyUpstreamGetBody(req, nil) - assert.Nil(t, req.GetBody) + ApplyUpstreamBodyMetadata(req, body) - applyUpstreamGetBody(req, &relaycommon.RelayInfo{}) - assert.Nil(t, req.GetBody) + assert.EqualValues(t, len(payload), req.ContentLength) + rc, err := req.GetBody() + require.NoError(t, err) + got, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Equal(t, "existing", string(got)) } -func TestApplyUpstreamBodyMetadata_EmptyStorageRemainsReplayable(t *testing.T) { +func TestApplyUpstreamBodyMetadataEmptyStorageRemainsReplayable(t *testing.T) { t.Parallel() storage, err := common.CreateBodyStorage(nil) require.NoError(t, err) defer storage.Close() + body := common.NewReplayableBodyReader(storage) - req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", common.ReaderOnly(storage)) + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", body) require.NoError(t, err) - ApplyUpstreamBodyMetadata(req, &relaycommon.RelayInfo{ - UpstreamRequestBodySize: storage.Size(), - UpstreamRequestGetBody: storage.NewReader, - }) + ApplyUpstreamBodyMetadata(req, body) assert.Zero(t, req.ContentLength) require.NotNil(t, req.GetBody) @@ -143,45 +180,6 @@ func TestApplyUpstreamBodyMetadata_EmptyStorageRemainsReplayable(t *testing.T) { assert.Empty(t, replay) } -func TestUpstreamBodyMetadataIsReboundAcrossChannelAttempts(t *testing.T) { - firstPayload := []byte(`{"attempt":"first-with-longer-body"}`) - _, firstSize, firstGetBody, firstCloser, err := relaycommon.NewOutboundJSONBody(firstPayload) - require.NoError(t, err) - - info := &relaycommon.RelayInfo{ - UpstreamRequestBodySize: firstSize, - UpstreamRequestGetBody: firstGetBody, - } - require.NoError(t, firstCloser.Close()) - _, err = firstGetBody() - require.ErrorIs(t, err, common.ErrStorageClosed) - - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - info.InitChannelMeta(c) - assert.Zero(t, info.UpstreamRequestBodySize) - assert.Nil(t, info.UpstreamRequestGetBody) - - secondPayload := []byte(`{"attempt":"second"}`) - secondStorage, err := common.CreateBodyStorage(secondPayload) - require.NoError(t, err) - defer secondStorage.Close() - info.UpstreamRequestBodySize = secondStorage.Size() - info.UpstreamRequestGetBody = secondStorage.NewReader - - req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", common.ReaderOnly(secondStorage)) - require.NoError(t, err) - ApplyUpstreamBodyMetadata(req, info) - - assert.EqualValues(t, len(secondPayload), req.ContentLength) - require.NotNil(t, req.GetBody) - rc, err := req.GetBody() - require.NoError(t, err) - replay, err := io.ReadAll(rc) - require.NoError(t, err) - require.NoError(t, rc.Close()) - assert.Equal(t, secondPayload, replay) -} - // stubTaskAdaptor implements just enough of TaskAdaptor for DoTaskApiRequest. type stubTaskAdaptor struct { TaskAdaptor @@ -449,14 +447,11 @@ func newH2PriorKnowledgeClient(ln net.Listener) (*http.Client, *http2.Transport) return &http.Client{Transport: transport, Timeout: 15 * time.Second}, transport } -func newPassThroughBody(t *testing.T, payload []byte) (io.Reader, *relaycommon.RelayInfo, common.BodyStorage) { +func newPassThroughBody(t *testing.T, payload []byte) (common.ReplayableBody, common.BodyStorage) { t.Helper() storage, err := common.CreateBodyStorage(payload) require.NoError(t, err) - return common.ReaderOnly(storage), &relaycommon.RelayInfo{ - UpstreamRequestBodySize: storage.Size(), - UpstreamRequestGetBody: storage.NewReader, - }, storage + return common.NewReplayableBodyReader(storage), storage } // TestUpstreamGetBody_HTTP2RetryAfterUpstreamStreamReset exercises the actual @@ -475,19 +470,15 @@ func TestUpstreamGetBody_HTTP2RetryAfterUpstreamStreamReset(t *testing.T) { client, transport := newH2PriorKnowledgeClient(ln) defer transport.CloseIdleConnections() - // Build the upstream request exactly the way DoApiRequest does: a - // type-erased BodyStorage reader plus the applyUpstream* helpers. - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(payload) + // Build the upstream request exactly the way DoApiRequest does: pass the + // original replayable body to the metadata helper after NewRequest. + body, closer, err := relaycommon.NewOutboundJSONBody(payload) require.NoError(t, err) defer closer.Close() req, err := http.NewRequest(http.MethodPost, "http://upstream.test/v1/chat/completions", body) require.NoError(t, err) - info := &relaycommon.RelayInfo{ - UpstreamRequestBodySize: size, - UpstreamRequestGetBody: getBody, - } - ApplyUpstreamBodyMetadata(req, info) + ApplyUpstreamBodyMetadata(req, body) require.NotNil(t, req.GetBody) resp, err := client.Do(req) @@ -514,11 +505,11 @@ func TestUpstreamGetBody_HTTP2RetryAfterUpstreamStreamReset_PassThrough(t *testi client, transport := newH2PriorKnowledgeClient(ln) defer transport.CloseIdleConnections() - body, info, storage := newPassThroughBody(t, payload) + body, storage := newPassThroughBody(t, payload) defer storage.Close() req, err := http.NewRequest(http.MethodPost, "http://upstream.test/v1/chat/completions", body) require.NoError(t, err) - ApplyUpstreamBodyMetadata(req, info) + ApplyUpstreamBodyMetadata(req, body) require.NotNil(t, req.GetBody) assert.EqualValues(t, len(payload), req.ContentLength) @@ -546,11 +537,11 @@ func TestUpstreamGetBody_HTTP2RetryAfterGracefulGoAway_PassThrough(t *testing.T) client, transport := newH2PriorKnowledgeClient(ln) defer transport.CloseIdleConnections() - body, info, storage := newPassThroughBody(t, payload) + body, storage := newPassThroughBody(t, payload) defer storage.Close() req, err := http.NewRequest(http.MethodPost, "http://upstream.test/v1/chat/completions", body) require.NoError(t, err) - ApplyUpstreamBodyMetadata(req, info) + ApplyUpstreamBodyMetadata(req, body) require.NotNil(t, req.GetBody) resp, err := client.Do(req) @@ -580,13 +571,13 @@ func TestUpstreamGetBody_HTTP2CannotRetryWithoutGetBody(t *testing.T) { client, transport := newH2PriorKnowledgeClient(ln) defer transport.CloseIdleConnections() - body, size, _, closer, err := relaycommon.NewOutboundJSONBody(payload) + body, closer, err := relaycommon.NewOutboundJSONBody(payload) require.NoError(t, err) defer closer.Close() req, err := http.NewRequest(http.MethodPost, "http://upstream.test/v1/chat/completions", body) require.NoError(t, err) - applyUpstreamContentLength(req, &relaycommon.RelayInfo{UpstreamRequestBodySize: size}) + req.ContentLength = body.Size() assert.Nil(t, req.GetBody) resp, err := client.Do(req) //nolint:bodyclose // Do fails, no body to close diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go index d2f9c389e41d..776c1829a03e 100644 --- a/relay/channel/jimeng/adaptor.go +++ b/relay/channel/jimeng/adaptor.go @@ -112,7 +112,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - channel.ApplyUpstreamBodyMetadata(req, info) + channel.ApplyUpstreamBodyMetadata(req, requestBody) err = Sign(c, req, info.ApiKey) if err != nil { return nil, fmt.Errorf("setup request header failed: %w", err) diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go index 59a7a258b2db..7f81e5335ebb 100644 --- a/relay/channel/task/sora/adaptor.go +++ b/relay/channel/task/sora/adaptor.go @@ -216,9 +216,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn return &buf, nil } - info.UpstreamRequestBodySize = storage.Size() - info.UpstreamRequestGetBody = storage.NewReader - return common.ReaderOnly(storage), nil + return common.NewReplayableBodyReader(storage), nil } // DoRequest delegates to common helper. diff --git a/relay/channel/task/sora/adaptor_test.go b/relay/channel/task/sora/adaptor_test.go index ef514952401c..7021f2a9d6dc 100644 --- a/relay/channel/task/sora/adaptor_test.go +++ b/relay/channel/task/sora/adaptor_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestSoraBuildRequestBodyBindsReplayMetadataForPassThrough(t *testing.T) { +func TestSoraBuildRequestBodyReturnsReplayablePassThroughBody(t *testing.T) { payload := []byte("opaque-sora-request-body") c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", bytes.NewReader(payload)) @@ -24,14 +24,15 @@ func TestSoraBuildRequestBodyBindsReplayMetadataForPassThrough(t *testing.T) { info := &relaycommon.RelayInfo{} body, err := (&TaskAdaptor{}).BuildRequestBody(c, info) require.NoError(t, err) + replayable, ok := body.(common.ReplayableBody) + require.True(t, ok) sent, err := io.ReadAll(body) require.NoError(t, err) assert.Equal(t, payload, sent) - assert.EqualValues(t, len(payload), info.UpstreamRequestBodySize) - require.NotNil(t, info.UpstreamRequestGetBody) + assert.EqualValues(t, len(payload), replayable.Size()) - replayBody, err := info.UpstreamRequestGetBody() + replayBody, err := replayable.NewReader() require.NoError(t, err) replay, err := io.ReadAll(replayBody) require.NoError(t, err) diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 660cc8891af8..b8a6fc875872 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -128,14 +128,12 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody var requestBody io.Reader = body var httpResp *http.Response diff --git a/relay/claude_handler.go b/relay/claude_handler.go index bcbe99089f9c..c8b01f7ac8dc 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -159,9 +159,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } - info.UpstreamRequestBodySize = storage.Size() - info.UpstreamRequestGetBody = storage.NewReader - requestBody = common.ReaderOnly(storage) + requestBody = common.NewReplayableBodyReader(storage) } else { convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request) if err != nil { @@ -188,14 +186,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } logger.LogDebug(c, "requestBody: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody requestBody = body } diff --git a/relay/common/outbound_body.go b/relay/common/outbound_body.go index 0316cf74d11c..ac9e110e0eba 100644 --- a/relay/common/outbound_body.go +++ b/relay/common/outbound_body.go @@ -18,26 +18,14 @@ import ( // The caller MUST invoke closer.Close() once the upstream call has finished // (typically via defer) to release the disk file / memory accounting. // -// The returned reader is wrapped with common.ReaderOnly to prevent the HTTP -// transport from prematurely closing the underlying BodyStorage. The returned -// size is meant to be propagated to http.Request.ContentLength because the -// type-erased io.Reader prevents net/http from auto-detecting it. -// -// The returned getBody hands out a new, independent reader over the full body -// on every call, per the http.Request.GetBody contract of returning a fresh -// copy of the body. It is meant to be propagated to http.Request.GetBody -// (which net/http likewise cannot derive from a type-erased io.Reader) so the -// HTTP/2 transport can transparently retry the request when the upstream -// resets the stream after the body was already written ("http2: Transport: -// cannot retry err ... after Request.Body was written"). Each reader has its -// own cursor — in memory mode a fresh bytes.Reader over the shared immutable -// backing array, in disk mode a separate file descriptor — so replays never -// share seek state with the primary body or with each other, and closing a -// replayed reader never releases the underlying storage. -func NewOutboundJSONBody(data []byte) (body io.Reader, size int64, getBody func() (io.ReadCloser, error), closer io.Closer, err error) { +// The returned body exposes its size and replay capability without exposing +// io.Closer. Request construction uses that metadata to populate ContentLength +// and GetBody, while the caller retains ownership of the underlying storage +// through the separately returned closer. +func NewOutboundJSONBody(data []byte) (body common.ReplayableBody, closer io.Closer, err error) { storage, err := common.CreateBodyStorage(data) if err != nil { - return nil, 0, nil, nil, err + return nil, nil, err } - return common.ReaderOnly(storage), storage.Size(), storage.NewReader, storage, nil + return common.NewReplayableBodyReader(storage), storage, nil } diff --git a/relay/common/outbound_body_test.go b/relay/common/outbound_body_test.go index 23e2ff959187..28c3aa699c8f 100644 --- a/relay/common/outbound_body_test.go +++ b/relay/common/outbound_body_test.go @@ -14,12 +14,11 @@ func TestNewOutboundJSONBody_GetBodyReplaysFullBody(t *testing.T) { payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"hello"}]}`) - body, size, getBody, closer, err := NewOutboundJSONBody(payload) + body, closer, err := NewOutboundJSONBody(payload) require.NoError(t, err) defer closer.Close() - assert.EqualValues(t, len(payload), size) - require.NotNil(t, getBody) + assert.EqualValues(t, len(payload), body.Size()) // Consume the primary body, as the HTTP transport does on the first attempt. first, err := io.ReadAll(body) @@ -29,7 +28,7 @@ func TestNewOutboundJSONBody_GetBodyReplaysFullBody(t *testing.T) { // GetBody must hand out the complete body again — and repeatedly, since the // transport may need more than one retry. for i := 0; i < 2; i++ { - rc, err := getBody() + rc, err := body.NewReader() require.NoError(t, err) replay, err := io.ReadAll(rc) require.NoError(t, err) @@ -43,7 +42,7 @@ func TestNewOutboundJSONBody_GetBodyAfterPartialRead(t *testing.T) { payload := []byte(`{"model":"test-model","input":"0123456789"}`) - body, _, getBody, closer, err := NewOutboundJSONBody(payload) + body, closer, err := NewOutboundJSONBody(payload) require.NoError(t, err) defer closer.Close() @@ -52,7 +51,7 @@ func TestNewOutboundJSONBody_GetBodyAfterPartialRead(t *testing.T) { _, err = io.ReadFull(body, partial) require.NoError(t, err) - rc, err := getBody() + rc, err := body.NewReader() require.NoError(t, err) replay, err := io.ReadAll(rc) require.NoError(t, err) @@ -61,7 +60,7 @@ func TestNewOutboundJSONBody_GetBodyAfterPartialRead(t *testing.T) { // Closing the replayed body must not close the underlying storage: the // handler owns the storage lifetime via the returned closer. require.NoError(t, rc.Close()) - rc2, err := getBody() + rc2, err := body.NewReader() require.NoError(t, err) replay2, err := io.ReadAll(rc2) require.NoError(t, err) @@ -73,7 +72,7 @@ func TestNewOutboundJSONBody_GetBodyAfterPartialRead(t *testing.T) { // independent cursors, per the http.Request.GetBody contract of returning a // new copy of the body: interleaved reads across two replay readers and the // primary body each observe exactly their own byte stream. -func assertIndependentReplayReaders(t *testing.T, payload []byte, body io.Reader, getBody func() (io.ReadCloser, error)) { +func assertIndependentReplayReaders(t *testing.T, payload []byte, body common.ReplayableBody) { t.Helper() half := len(payload) / 2 @@ -87,9 +86,9 @@ func assertIndependentReplayReaders(t *testing.T, payload []byte, body io.Reader // Interleave two replay readers: A reads half, B reads everything, then A // reads the rest. - a, err := getBody() + a, err := body.NewReader() require.NoError(t, err) - b, err := getBody() + b, err := body.NewReader() require.NoError(t, err) aHead := make([]byte, half) @@ -118,16 +117,16 @@ func TestNewOutboundJSONBody_GetBodyReadersAreIndependent(t *testing.T) { payload := []byte(`{"model":"test-model","input":"abcdefghijklmnopqrstuvwxyz"}`) - body, _, getBody, closer, err := NewOutboundJSONBody(payload) + body, closer, err := NewOutboundJSONBody(payload) require.NoError(t, err) defer closer.Close() - assertIndependentReplayReaders(t, payload, body, getBody) + assertIndependentReplayReaders(t, payload, body) // Once the handler releases the storage, GetBody must fail loudly instead // of replaying stale data. require.NoError(t, closer.Close()) - _, err = getBody() + _, err = body.NewReader() require.ErrorIs(t, err, common.ErrStorageClosed) } @@ -147,7 +146,7 @@ func TestNewOutboundJSONBody_GetBodyReadersAreIndependent_DiskStorage(t *testing payload := []byte(`{"model":"test-model","input":"abcdefghijklmnopqrstuvwxyz"}`) - body, _, getBody, closer, err := NewOutboundJSONBody(payload) + body, closer, err := NewOutboundJSONBody(payload) require.NoError(t, err) defer closer.Close() @@ -155,9 +154,9 @@ func TestNewOutboundJSONBody_GetBodyReadersAreIndependent_DiskStorage(t *testing require.True(t, ok) assert.True(t, storage.IsDisk(), "the payload must have taken the diskStorage path") - assertIndependentReplayReaders(t, payload, body, getBody) + assertIndependentReplayReaders(t, payload, body) require.NoError(t, closer.Close()) - _, err = getBody() + _, err = body.NewReader() require.ErrorIs(t, err, common.ErrStorageClosed) } diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 52c741158c3e..594e2640182d 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "strconv" "strings" "time" @@ -150,23 +149,6 @@ type RelayInfo struct { UseRuntimeHeadersOverride bool ParamOverrideAudit []string - // UpstreamRequestBodySize is the byte size of the marshaled upstream request - // body. It is set when the body is wrapped in a BodyStorage (see - // relay/common/outbound_body.go), so that DoApiRequest can populate - // http.Request.ContentLength manually (net/http only auto-detects it for - // *bytes.Reader/Buffer/strings.Reader). 0 means "let net/http decide". - UpstreamRequestBodySize int64 - - // UpstreamRequestGetBody returns a fresh reader over the full marshaled - // upstream request body. It is set alongside UpstreamRequestBodySize when - // the body is wrapped in a BodyStorage (see relay/common/outbound_body.go), - // so that DoApiRequest can populate http.Request.GetBody manually (net/http - // only auto-populates it for *bytes.Reader/Buffer/strings.Reader). Without - // GetBody the HTTP/2 transport cannot transparently retry a request whose - // stream was reset by the upstream after the body was already written. - // nil means "no safe replay available". - UpstreamRequestGetBody func() (io.ReadCloser, error) - PriceData hosttypes.PriceData // QuotaClamp is set (non-nil) when a quota conversion saturated at the @@ -204,12 +186,6 @@ type RelayInfo struct { } func (info *RelayInfo) InitChannelMeta(c *gin.Context) { - // RelayInfo is reused across channel attempts. Body metadata belongs to the - // current attempt and may reference storage that its handler has closed, so - // discard it before the next channel binds its outbound body. - info.UpstreamRequestBodySize = 0 - info.UpstreamRequestGetBody = nil - channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride) diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go index edf5fdbf5546..9e58f3f92d8b 100644 --- a/relay/common/relay_info_test.go +++ b/relay/common/relay_info_test.go @@ -1,32 +1,14 @@ package common import ( - "io" - "net/http/httptest" "testing" "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta" "github.com/QuantumNous/new-api/relaykit/types" - "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestInitChannelMetaClearsUpstreamBodyMetadata(t *testing.T) { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - info := &RelayInfo{ - UpstreamRequestBodySize: 37, - UpstreamRequestGetBody: func() (io.ReadCloser, error) { - return nil, nil - }, - } - - info.InitChannelMeta(c) - - assert.Zero(t, info.UpstreamRequestBodySize) - assert.Nil(t, info.UpstreamRequestGetBody) -} - func TestRelayInfoGetFinalRequestRelayFormatPrefersExplicitFinal(t *testing.T) { info := &RelayInfo{ RelayFormat: types.RelayFormatOpenAI, diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 24f355f9d4f5..8edb3362b6e0 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -104,9 +104,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types logger.LogDebug(c, "requestBody: %s", debugBytes) } } - info.UpstreamRequestBodySize = storage.Size() - info.UpstreamRequestGetBody = storage.NewReader - requestBody = common.ReaderOnly(storage) + requestBody = common.NewReplayableBodyReader(storage) } else { convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request) if err != nil { @@ -177,14 +175,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types logger.LogDebug(c, "text request body: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody requestBody = body } diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index d3b617b656e4..44d3de486dc4 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -58,14 +58,12 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } logger.LogDebug(c, "converted embedding request body: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody var requestBody io.Reader = body statusCodeMappingStr := c.GetString("status_code_mapping") resp, err := adaptor.DoRequest(c, info, requestBody) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 38ac0d87de45..57010d87c380 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -141,9 +141,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } - info.UpstreamRequestBodySize = storage.Size() - info.UpstreamRequestGetBody = storage.NewReader - requestBody = common.ReaderOnly(storage) + requestBody = common.NewReplayableBodyReader(storage) } else { // 使用 ConvertGeminiRequest 转换请求格式 convertedRequest, err := adaptor.ConvertGeminiRequest(c, info, request) @@ -166,14 +164,12 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ logger.LogDebug(c, "Gemini request body: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody requestBody = body } @@ -272,14 +268,12 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI } } logger.LogDebug(c, "Gemini embedding request body: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody requestBody = body resp, err := adaptor.DoRequest(c, info, requestBody) diff --git a/relay/image_handler.go b/relay/image_handler.go index 2d99a63c60d7..690f229f3ff0 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -51,9 +51,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } - info.UpstreamRequestBodySize = storage.Size() - info.UpstreamRequestGetBody = storage.NewReader - requestBody = common.ReaderOnly(storage) + requestBody = common.NewReplayableBodyReader(storage) } else { convertedRequest, err := adaptor.ConvertImageRequest(c, info, *request) if err != nil { @@ -79,14 +77,12 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type } logger.LogDebug(c, "image request body: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody requestBody = body } } diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index e2546dda9640..460fe3056801 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -47,9 +47,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } - info.UpstreamRequestBodySize = storage.Size() - info.UpstreamRequestGetBody = storage.NewReader - requestBody = common.ReaderOnly(storage) + requestBody = common.NewReplayableBodyReader(storage) } else { convertedRequest, err := adaptor.ConvertRerankRequest(c, info.RelayMode, *request) if err != nil { @@ -70,14 +68,12 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } logger.LogDebug(c, "Rerank request body: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody requestBody = body } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 2531b3a63bd1..f8b4dec2be7b 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -82,9 +82,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry()) } - info.UpstreamRequestBodySize = storage.Size() - info.UpstreamRequestGetBody = storage.NewReader - requestBody = common.ReaderOnly(storage) + requestBody = common.NewReplayableBodyReader(storage) } else { convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *request) if err != nil { @@ -111,14 +109,12 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } logger.LogDebug(c, "requestBody: %s", jsonData) - body, size, getBody, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } defer closer.Close() jsonData = nil - info.UpstreamRequestBodySize = size - info.UpstreamRequestGetBody = getBody requestBody = body } From 0cd9dc85e334018d15c5a480e39753d0866e2035 Mon Sep 17 00:00:00 2001 From: RedwindA <128586631+RedwindA@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:53:55 +0800 Subject: [PATCH 03/18] Merge commit from fork --- controller/user.go | 13 ++---- model/user.go | 49 ++++++++++++++++++----- model/user_update_test.go | 83 ++++++++++++++++++++++++++++++++++----- router/api-router.go | 2 +- 4 files changed, 117 insertions(+), 30 deletions(-) diff --git a/controller/user.go b/controller/user.go index 59aaf5847238..9b8d931ec1f8 100644 --- a/controller/user.go +++ b/controller/user.go @@ -399,11 +399,6 @@ func GetUser(c *gin.Context) { func GenerateAccessToken(c *gin.Context) { id := c.GetInt("id") - user, err := model.GetUserById(id, true) - if err != nil { - common.ApiError(c, err) - return - } // get rand int 28-32 randI := common.GetRandomInt(4) key, err := common.GenerateRandomKey(29 + randI) @@ -412,14 +407,12 @@ func GenerateAccessToken(c *gin.Context) { common.SysLog("failed to generate key: " + err.Error()) return } - user.SetAccessToken(key) - - if model.DB.Where("access_token = ?", user.AccessToken).First(user).RowsAffected != 0 { + if model.DB.Where("access_token = ?", key).First(&model.User{}).RowsAffected != 0 { common.ApiErrorI18n(c, i18n.MsgUuidDuplicate) return } - if err := user.Update(false); err != nil { + if err := model.UpdateUserAccessToken(id, key); err != nil { common.ApiError(c, err) return } @@ -427,7 +420,7 @@ func GenerateAccessToken(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": user.AccessToken, + "data": key, }) return } diff --git a/model/user.go b/model/user.go index b25de5e75efa..eb4ea08642d5 100644 --- a/model/user.go +++ b/model/user.go @@ -139,6 +139,22 @@ func (user *User) SetAccessToken(token string) { user.AccessToken = &token } +// UpdateUserAccessToken rotates a dashboard personal access token without +// writing a stale user snapshot back over concurrently updated fields. +func UpdateUserAccessToken(id int, token string) error { + if id == 0 { + return errors.New("id 为空!") + } + result := DB.Model(&User{}).Where("id = ?", id).Update("access_token", token) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + func (user *User) GetSetting() dto.UserSetting { setting := dto.UserSetting{} if user.Setting != "" { @@ -489,15 +505,19 @@ func HardDeleteUserById(id int) error { return user.HardDelete() } -func inviteUser(inviterId int) (err error) { - user, err := GetUserById(inviterId, true) - if err != nil { - return err +func inviteUser(inviterId int) error { + result := DB.Model(&User{}).Where("id = ?", inviterId).Updates(map[string]interface{}{ + "aff_count": gorm.Expr("aff_count + ?", 1), + "aff_quota": gorm.Expr("aff_quota + ?", common.QuotaForInviter), + "aff_history": gorm.Expr("aff_history + ?", common.QuotaForInviter), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound } - user.AffCount++ - user.AffQuota += common.QuotaForInviter - user.AffHistoryQuota += common.QuotaForInviter - return DB.Save(user).Error + return nil } func (user *User) TransferAffQuotaToQuota(quota int) error { @@ -514,7 +534,7 @@ func (user *User) TransferAffQuotaToQuota(quota int) error { defer tx.Rollback() // 确保在函数退出时事务能回滚 // 加锁查询用户以确保数据一致性 - err := lockForUpdate(tx).First(&user, user.Id).Error + err := lockForUpdate(tx).First(user, user.Id).Error if err != nil { return err } @@ -748,7 +768,16 @@ func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error { return err } } - if err = tx.Model(¤t).Omit("quota", "used_quota", "request_count", "auth_version").Updates(newUser).Error; err != nil { + if err = tx.Model(¤t).Omit( + "access_token", + "quota", + "used_quota", + "request_count", + "aff_count", + "aff_quota", + "aff_history", + "auth_version", + ).Updates(newUser).Error; err != nil { return err } return tx.First(user, user.Id).Error diff --git a/model/user_update_test.go b/model/user_update_test.go index c68d8f33c85a..8e69ba1ebba2 100644 --- a/model/user_update_test.go +++ b/model/user_update_test.go @@ -27,19 +27,23 @@ func setupUserUpdateTestState(t *testing.T) { }) } -func TestUserUpdateDoesNotOverwriteAccountingFields(t *testing.T) { +func TestUserUpdateDoesNotOverwriteConcurrentAccountingOrTokenChanges(t *testing.T) { setupUserUpdateTestState(t) user := User{ - Id: 1, - Username: "quota-race-user", - Password: "password", - DisplayName: "before", - Status: common.UserStatusEnabled, - Quota: 1000, - UsedQuota: 20, - RequestCount: 3, + Id: 1, + Username: "quota-race-user", + Password: "password", + DisplayName: "before", + Status: common.UserStatusEnabled, + Quota: 1000, + UsedQuota: 20, + RequestCount: 3, + AffCount: 2, + AffQuota: 800, + AffHistoryQuota: 1200, } + user.SetAccessToken("old-token") require.NoError(t, DB.Create(&user).Error) staleUser, err := GetUserById(user.Id, true) @@ -49,6 +53,10 @@ func TestUserUpdateDoesNotOverwriteAccountingFields(t *testing.T) { "quota": gorm.Expr("quota - ?", 400), "used_quota": gorm.Expr("used_quota + ?", 400), "request_count": gorm.Expr("request_count + ?", 1), + "aff_count": gorm.Expr("aff_count + ?", 1), + "aff_quota": gorm.Expr("aff_quota - ?", 500), + "aff_history": gorm.Expr("aff_history + ?", 500), + "access_token": "rotated-token", }).Error) staleUser.DisplayName = "after" @@ -60,6 +68,63 @@ func TestUserUpdateDoesNotOverwriteAccountingFields(t *testing.T) { assert.Equal(t, 600, got.Quota) assert.Equal(t, 420, got.UsedQuota) assert.Equal(t, 4, got.RequestCount) + assert.Equal(t, 3, got.AffCount) + assert.Equal(t, 300, got.AffQuota) + assert.Equal(t, 1700, got.AffHistoryQuota) + assert.Equal(t, "rotated-token", got.GetAccessToken()) +} + +func TestUpdateUserAccessTokenOnlyUpdatesAccessToken(t *testing.T) { + setupUserUpdateTestState(t) + + user := User{ + Id: 2, + Username: "token-rotation-user", + Password: "password", + DisplayName: "before", + Status: common.UserStatusEnabled, + Quota: 1000, + AffQuota: 800, + AffHistoryQuota: 1200, + } + require.NoError(t, DB.Create(&user).Error) + + require.NoError(t, DB.Model(&User{}).Where("id = ?", user.Id).Updates(map[string]interface{}{ + "quota": gorm.Expr("quota + ?", 500), + "aff_quota": gorm.Expr("aff_quota - ?", 500), + "display_name": "concurrent-update", + }).Error) + + require.NoError(t, UpdateUserAccessToken(user.Id, "rotated-token")) + + var got User + require.NoError(t, DB.First(&got, user.Id).Error) + assert.Equal(t, "rotated-token", got.GetAccessToken()) + assert.Equal(t, "concurrent-update", got.DisplayName) + assert.Equal(t, 1500, got.Quota) + assert.Equal(t, 300, got.AffQuota) + assert.Equal(t, 1200, got.AffHistoryQuota) +} + +func TestUpdateUserAccessTokenRejectsSoftDeletedUser(t *testing.T) { + setupUserUpdateTestState(t) + + user := User{ + Id: 3, + Username: "deleted-token-rotation-user", + Password: "password", + Status: common.UserStatusEnabled, + } + user.SetAccessToken("old-token") + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, DB.Delete(&user).Error) + + err := UpdateUserAccessToken(user.Id, "orphaned-token") + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + + var got User + require.NoError(t, DB.Unscoped().First(&got, user.Id).Error) + assert.Equal(t, "old-token", got.GetAccessToken()) } func TestUpdateUserSettingOnlyUpdatesSetting(t *testing.T) { diff --git a/router/api-router.go b/router/api-router.go index 907cf1ed2885..2b0bfd6c5dd3 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -90,7 +90,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/models", controller.GetUserModels) selfRoute.PUT("/self", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) - selfRoute.GET("/token", middleware.DisableCache(), controller.GenerateAccessToken) + selfRoute.GET("/token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GenerateAccessToken) selfRoute.GET("/passkey", controller.PasskeyStatus) selfRoute.POST("/passkey/register/begin", middleware.DisableCache(), controller.PasskeyRegisterBegin) selfRoute.POST("/passkey/register/finish", middleware.DisableCache(), controller.PasskeyRegisterFinish) From c9bc038649d1d1f6f1fe9d6bca3b09f842cdcf6b Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:34:56 +0800 Subject: [PATCH 04/18] feat(channels): refine fetched model categorization (#6632) * feat(channels): refine fetched model categorization * fix: channel category * fix: hy3 category --- .../dialogs/fetch-models-dialog.tsx | 341 +++++++++--------- web/src/features/channels/lib/index.ts | 1 + .../features/channels/lib/model-categories.ts | 175 +++++++++ 3 files changed, 339 insertions(+), 178 deletions(-) create mode 100644 web/src/features/channels/lib/model-categories.ts diff --git a/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx index cca720d97b5d..6d7ce554eefa 100644 --- a/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { useQueryClient } from '@tanstack/react-query' import { Loader2, Search, Info, ChevronDown } from 'lucide-react' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -41,17 +41,16 @@ import { import { fetchUpstreamModels, updateChannel } from '../../api' import { - channelsQueryKeys, + categorizeModels, categorizeModelsWithRedirect, + channelsQueryKeys, normalizeModelName, parseModelsString, } from '../../lib' import { useChannels } from '../channels-provider' function normalizeModelNameList(models: readonly string[]): string[] { - return Array.from( - new Set(models.map((m) => normalizeModelName(m)).filter(Boolean)) - ) + return [...new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))] } type FetchModelsDialogProps = { @@ -140,8 +139,8 @@ export function FetchModelsDialog({ setFetchedModels(list) setSelectedModels(existingModels) toast.success(t('Fetched {{count}} models', { count: list.length })) - } else { - const response = await fetchUpstreamModels(activeChannel!.id) + } else if (activeChannel) { + const response = await fetchUpstreamModels(activeChannel.id) if (response.success) { const list = Array.isArray(response.data) ? response.data : [] setFetchedModels(list) @@ -202,45 +201,6 @@ export function FetchModelsDialog({ onOpenChange(false) } - // Categorize models by common prefixes - const categorizeModels = (models: string[]) => { - const categories: Record = {} - - models.forEach((model) => { - let category = 'Other' - - // Determine category based on model name - if ( - model.toLowerCase().includes('gpt') || - model.toLowerCase().includes('o1') || - model.toLowerCase().includes('o3') - ) { - category = 'OpenAI' - } else if (model.toLowerCase().includes('claude')) { - category = 'Anthropic' - } else if (model.toLowerCase().includes('gemini')) { - category = 'Gemini' - } else if (model.toLowerCase().includes('qwen')) { - category = 'Qwen' - } else if (model.toLowerCase().includes('deepseek')) { - category = 'DeepSeek' - } else if (model.toLowerCase().includes('glm')) { - category = 'Zhipu' - } else if (model.toLowerCase().includes('llama')) { - category = 'Meta' - } else if (model.toLowerCase().includes('mistral')) { - category = 'Mistral' - } - - if (!categories[category]) { - categories[category] = [] - } - categories[category].push(model) - }) - - return categories - } - // Filter models by search const filteredModels = useMemo(() => { if (!searchKeyword) return fetchedModels @@ -249,18 +209,30 @@ export function FetchModelsDialog({ ) }, [fetchedModels, searchKeyword]) - // Helper to check if a model is considered "existing" (in selected or redirect) - const isExistingModel = (model: string) => - classificationSet.has(normalizeModelName(model)) - - // Separate new and existing models - const newModels = filteredModels.filter((m) => !isExistingModel(m)) - const existingFilteredModels = filteredModels.filter((m) => - isExistingModel(m) - ) + const { + newModels, + existingFilteredModels, + newModelsByCategory, + existingModelsByCategory, + } = useMemo(() => { + const newModels: string[] = [] + const existingFilteredModels: string[] = [] + + for (const model of filteredModels) { + if (classificationSet.has(normalizeModelName(model))) { + existingFilteredModels.push(model) + } else { + newModels.push(model) + } + } - const newModelsByCategory = categorizeModels(newModels) - const existingModelsByCategory = categorizeModels(existingFilteredModels) + return { + newModels, + existingFilteredModels, + newModelsByCategory: categorizeModels(newModels), + existingModelsByCategory: categorizeModels(existingFilteredModels), + } + }, [classificationSet, filteredModels]) // 厂商分类按 a-z 排序,Other 放最后,便于查找 const getSortedCategoryEntries = ( @@ -345,7 +317,7 @@ export function FetchModelsDialog({ } - > + /> {t('From model redirect, not yet added to models list')} @@ -365,24 +337,143 @@ export function FetchModelsDialog({ !isFetching && (fetchedModels.length > 0 || removedModels.length > 0) + let dialogDescription: ReactNode = t('Fetch available models from upstream') + if (activeChannel) { + dialogDescription = ( + <> + {t('Channel:')} {activeChannel.name} + + ) + } else if (channelName) { + dialogDescription = ( + <> + {t('Channel:')} {channelName} + + ) + } + + let defaultTab = 'existing' + if (newModels.length > 0) { + defaultTab = 'new' + } else if (removedModels.length > 0) { + defaultTab = 'removed' + } + + let dialogBody: ReactNode + if (!activeChannel && !customFetcher) { + dialogBody = ( +
+ {t('No channel selected')} +
+ ) + } else if (isFetching) { + dialogBody = ( +
+ +
+ ) + } else if (fetchedModels.length === 0 && removedModels.length === 0) { + dialogBody = ( +
+

{t('No models fetched yet.')}

+ +
+ ) + } else { + dialogBody = ( +
+ {/* Search Bar */} +
+ + setSearchKeyword(e.target.value)} + className='pl-9' + /> +
+ + {/* Tabs for New vs Existing vs Removed */} + + 0 ? 'grid-cols-3' : 'grid-cols-2'}`} + > + + {t('New Models ({{count}})', { count: newModels.length })} + + + {t('Existing Models ({{count}})', { + count: existingFilteredModels.length, + })} + + {removedModels.length > 0 && ( + + {t('Removed Models ({{count}})', { + count: removedModels.length, + })} + + )} + + + + {getSortedCategoryEntries(newModelsByCategory).map( + ([category, models]) => renderModelCategory(category, models) + )} + + + + {getSortedCategoryEntries(existingModelsByCategory).map( + ([category, models]) => renderModelCategory(category, models) + )} + + + {removedModels.length > 0 && ( + +

+ {t( + 'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.' + )} +

+ {renderModelCategory(t('Removed'), removedModels)} +
+ )} +
+ + {/* Selection Summary */} +
+ {t('{{n}} model(s) selected', { n: selectedModels.length })} +
+
+ ) + } + return ( - {t('Channel:')} {activeChannel.name} - - ) : channelName ? ( - <> - {t('Channel:')} {channelName} - - ) : ( - t('Fetch available models from upstream') - ) - } + description={dialogDescription} contentClassName='max-w-3xl' contentHeight='auto' bodyClassName='space-y-4' @@ -400,113 +491,7 @@ export function FetchModelsDialog({ ) : null } > - {!activeChannel && !customFetcher ? ( -
- {t('No channel selected')} -
- ) : isFetching ? ( -
- -
- ) : fetchedModels.length === 0 && removedModels.length === 0 ? ( -
-

{t('No models fetched yet.')}

- -
- ) : ( - <> -
- {/* Search Bar */} -
- - setSearchKeyword(e.target.value)} - className='pl-9' - /> -
- - {/* Tabs for New vs Existing vs Removed */} - 0 - ? 'new' - : removedModels.length > 0 - ? 'removed' - : 'existing' - } - > - 0 ? 'grid-cols-3' : 'grid-cols-2'}`} - > - - {t('New Models ({{count}})', { count: newModels.length })} - - - {t('Existing Models ({{count}})', { - count: existingFilteredModels.length, - })} - - {removedModels.length > 0 && ( - - {t('Removed Models ({{count}})', { - count: removedModels.length, - })} - - )} - - - - {getSortedCategoryEntries(newModelsByCategory).map( - ([category, models]) => renderModelCategory(category, models) - )} - - - - {getSortedCategoryEntries(existingModelsByCategory).map( - ([category, models]) => renderModelCategory(category, models) - )} - - - {removedModels.length > 0 && ( - -

- {t( - 'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.' - )} -

- {renderModelCategory(t('Removed'), removedModels)} -
- )} -
- - {/* Selection Summary */} -
- {t('{{n}} model(s) selected', { n: selectedModels.length })} -
-
- - )} + {dialogBody}
) } diff --git a/web/src/features/channels/lib/index.ts b/web/src/features/channels/lib/index.ts index 43eb7773b4cf..8c18151ceb5f 100644 --- a/web/src/features/channels/lib/index.ts +++ b/web/src/features/channels/lib/index.ts @@ -26,3 +26,4 @@ export * from './channel-type-config' export * from './channel-utils' export * from './multi-key-utils' export * from './model-mapping-validation' +export * from './model-categories' diff --git a/web/src/features/channels/lib/model-categories.ts b/web/src/features/channels/lib/model-categories.ts new file mode 100644 index 000000000000..6d49cf65db4e --- /dev/null +++ b/web/src/features/channels/lib/model-categories.ts @@ -0,0 +1,175 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +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 support@quantumnous.com +*/ + +type ModelCategoryRule = { + name: string + keywords?: readonly string[] + pattern?: RegExp +} + +// Rules are ordered so platform-specific IDs such as Perplexity's Sonar and +// NVIDIA's Nemotron take precedence over the base Llama/Mixtral family name. +const MODEL_CATEGORY_RULES: readonly ModelCategoryRule[] = [ + { name: 'Perplexity', keywords: ['perplexity', 'sonar-'] }, + { name: 'NVIDIA', keywords: ['nvidia/', 'nvidia.', 'nemotron'] }, + { + name: 'OpenAI', + keywords: [ + 'openai/', + 'openai.', + 'gpt-', + 'chatgpt-', + 'codex-', + 'dall-e-', + 'whisper-', + 'tts-', + 'omni-moderation-', + 'text-moderation-', + 'text-embedding-ada-', + 'text-embedding-3-', + 'text-ada-', + 'text-babbage-', + 'text-curie-', + 'davinci-', + 'babbage-', + 'computer-use-preview', + 'sora', + ], + pattern: /(?:^|[/.:])o(?:1|3|4)(?=$|[-.:])/, + }, + { name: 'Anthropic', keywords: ['anthropic', 'claude'] }, + { + name: 'Gemini', + keywords: [ + 'gemini', + 'gemma', + 'learnlm', + 'imagen', + 'veo', + 'nano-banana', + 'palm-', + ], + pattern: /(?:^|[/.:])aqa$/, + }, + { name: 'xAI', keywords: ['x-ai/', 'xai/', 'xai-', 'grok'] }, + { name: 'DeepSeek', keywords: ['deepseek'] }, + { + name: 'Qwen', + keywords: ['qwen', 'qwq-', 'qvq-', 'tongyi', 'gte-'], + pattern: /(?:^|[/.:])(?:text-embedding-v\d+|gui-plus|z-image)(?:$|[-_.:])/, + }, + { name: 'Wan', pattern: /(?:^|[/.:])wan(?:x?\d|[-_])/ }, + { name: 'Moonshot', keywords: ['moonshot', 'kimi-'] }, + { + name: 'MiniMax', + keywords: ['minimax', 'abab', 'hailuo'], + pattern: /^(?:t2v|i2v|s2v)-01(?:-|$)/, + }, + { + name: 'Doubao', + keywords: ['doubao', 'volcengine', 'seedance', 'seedream', 'seed-1-'], + }, + { + name: 'Zhipu', + keywords: ['zhipu', 'zai-org', 'thudm', 'chatglm', 'cogview', 'cogvideo'], + pattern: /(?:^|[/._-])glm(?=$|[-._])/, + }, + { name: 'Baidu', keywords: ['baidu', 'wenxin', 'ernie'] }, + { name: 'Yi', keywords: ['01-ai/'], pattern: /(?:^|[/.:])yi(?=$|[-_])/ }, + { name: 'iFlytek', keywords: ['iflytek', 'sparkdesk'] }, + { + name: 'Tencent', + keywords: ['tencent', 'hunyuan'], + pattern: /(?:^|[/.:])hy\d*(?=$|[-_.:])/, + }, + { name: 'Baichuan', keywords: ['baichuan'] }, + { name: 'InternLM', keywords: ['internlm'] }, + { name: 'StepFun', keywords: ['stepfun', 'step-'] }, + { name: 'MiMo', keywords: ['xiaomi', 'mimo-'] }, + { + name: 'Mistral', + keywords: [ + 'mistral', + 'mixtral', + 'codestral', + 'ministral', + 'pixtral', + 'magistral', + ], + }, + { name: 'Meta', keywords: ['meta-llama', 'llama-', 'llama2', 'llama3'] }, + { + name: 'Cohere', + keywords: ['cohere', 'command-', 'c4ai-aya', 'aya-'], + pattern: /(?:^|[/.:])command$/, + }, + { name: 'Jina', keywords: ['jinaai', 'jina-'] }, + { name: 'BAAI', keywords: ['baai/', 'bge-'] }, + { name: 'Black Forest Labs', keywords: ['black-forest-labs', 'flux.'] }, + { + name: 'Microsoft', + keywords: ['microsoft/'], + pattern: /(?:^|[/.:])phi(?=$|[-._])/, + }, + { + name: 'Amazon', + keywords: ['amazon/', 'amazon.', 'nova-', 'titan-'], + }, + { name: 'AI21 Labs', keywords: ['ai21', 'jamba'] }, + { + name: 'Stability AI', + keywords: ['stabilityai', 'stable-diffusion', 'stable-image', 'sdxl-'], + }, + { name: 'Nous Research', keywords: ['nousresearch', 'hermes-'] }, + { name: '360 AI', keywords: ['360gpt', '360zhinao'] }, + { name: 'Midjourney', keywords: ['midjourney', 'mj_', 'mj-', 'swap_face'] }, + { name: 'Kling', keywords: ['kling'] }, + { name: 'Vidu', keywords: ['vidu'] }, + { name: 'Suno', keywords: ['suno'] }, + { name: 'Jimeng', keywords: ['jimeng'] }, +] + +export function getModelCategory(modelName: string): string { + const normalizedName = modelName.trim().toLowerCase() + + for (const rule of MODEL_CATEGORY_RULES) { + if ( + rule.keywords?.some((keyword) => normalizedName.includes(keyword)) || + rule.pattern?.test(normalizedName) + ) { + return rule.name + } + } + + return 'Other' +} + +export function categorizeModels( + models: readonly string[] +): Record { + const categories: Record = {} + + for (const model of models) { + const category = getModelCategory(model) + categories[category] ??= [] + categories[category].push(model) + } + + return categories +} From b941253aea6b9bccf1bc8de503bf3477caafebfe Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:35:46 +0800 Subject: [PATCH 05/18] fix: test Claude/Gemini endpoints with native request format (#6698) --- controller/channel-test.go | 52 +++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index f6e6bd7f1163..f494af0431f6 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -151,6 +151,11 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te requestPath = "/v1/responses/compact" } } + // Gemini 原生流式通过 URL action(:streamGenerateContent)表达而非请求体字段, + // GeminiChatRequest.IsStream 依据请求 URL 判定,合成请求路径需与生产入口保持一致 + if isStream && constant.EndpointType(endpointType) == constant.EndpointTypeGemini { + requestPath = strings.Replace(requestPath, ":generateContent", ":streamGenerateContent", 1) + } if strings.HasPrefix(requestPath, "/v1/responses/compact") { testModel = ratio_setting.WithCompactModelSuffix(testModel) } @@ -371,14 +376,18 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te } } default: - // Chat/Completion 等其他请求类型 - if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok { - convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq) - } else { + switch req := request.(type) { + case *dto.GeneralOpenAIRequest: + convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, req) + case *dto.ClaudeRequest: + convertedRequest, err = adaptor.ConvertClaudeRequest(c, info, req) + case *dto.GeminiChatRequest: + convertedRequest, err = adaptor.ConvertGeminiRequest(c, info, req) + default: return testResult{ context: c, - localErr: errors.New("invalid general request type"), - newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed), + localErr: errors.New("invalid chat request type"), + newAPIError: types.NewError(errors.New("invalid chat request type"), types.ErrorCodeConvertRequestFailed), } } } @@ -733,12 +742,31 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel, Model: model, Input: testResponsesInput, } - case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: - // 返回 GeneralOpenAIRequest - maxTokens := uint(16) - if constant.EndpointType(endpointType) == constant.EndpointTypeGemini { - maxTokens = 3000 + case constant.EndpointTypeAnthropic: + return &dto.ClaudeRequest{ + Model: model, + Stream: lo.ToPtr(isStream), + MaxTokens: lo.ToPtr(uint(16)), + Messages: []dto.ClaudeMessage{ + { + Role: "user", + Content: "hi", + }, + }, + } + case constant.EndpointTypeGemini: + return &dto.GeminiChatRequest{ + Contents: []dto.GeminiChatContent{ + { + Role: "user", + Parts: []dto.GeminiPart{{Text: "hi"}}, + }, + }, + GenerationConfig: dto.GeminiChatGenerationConfig{ + MaxOutputTokens: lo.ToPtr(uint(3000)), + }, } + case constant.EndpointTypeOpenAI: req := &dto.GeneralOpenAIRequest{ Model: model, Stream: lo.ToPtr(isStream), @@ -748,7 +776,7 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel, Content: "hi", }, }, - MaxTokens: lo.ToPtr(maxTokens), + MaxTokens: lo.ToPtr(uint(16)), } if isStream { req.StreamOptions = &dto.StreamOptions{IncludeUsage: true} From 1da23d6b33421daf88a1a15a6821d6304940691a Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 6 Aug 2026 18:04:20 +0800 Subject: [PATCH 06/18] feat(rate-limit): add user critical rate limit middleware for access token and aff transfer routes --- middleware/rate-limit.go | 11 +++++++++++ router/api-router.go | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/middleware/rate-limit.go b/middleware/rate-limit.go index 350ee8ec0718..be1820ffee99 100644 --- a/middleware/rate-limit.go +++ b/middleware/rate-limit.go @@ -178,6 +178,17 @@ func CriticalRateLimit() func(c *gin.Context) { return defNext } +func UserCriticalRateLimit(scope string) func(c *gin.Context) { + if !common.CriticalRateLimitEnable { + return defNext + } + return userRateLimitFactory( + common.CriticalRateLimitNum, + common.CriticalRateLimitDuration, + "UC:"+scope, + ) +} + func DownloadRateLimit() func(c *gin.Context) { return rateLimitFactory(common.DownloadRateLimitNum, common.DownloadRateLimitDuration, "DW") } diff --git a/router/api-router.go b/router/api-router.go index 2b0bfd6c5dd3..31c595e00db2 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -90,7 +90,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/models", controller.GetUserModels) selfRoute.PUT("/self", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) - selfRoute.GET("/token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GenerateAccessToken) + selfRoute.GET("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken) selfRoute.GET("/passkey", controller.PasskeyStatus) selfRoute.POST("/passkey/register/begin", middleware.DisableCache(), controller.PasskeyRegisterBegin) selfRoute.POST("/passkey/register/finish", middleware.DisableCache(), controller.PasskeyRegisterFinish) @@ -110,7 +110,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) - selfRoute.POST("/aff_transfer", controller.TransferAffQuota) + selfRoute.POST("/aff_transfer", middleware.UserCriticalRateLimit("aff-transfer"), controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) // 2FA routes From e926e5cacee22fc838d94e8b95b438e825508e11 Mon Sep 17 00:00:00 2001 From: lihu-001 Date: Fri, 7 Aug 2026 17:06:32 +0800 Subject: [PATCH 07/18] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=85=91?= =?UTF-8?q?=E6=8D=A2=E7=A0=81=E9=A2=9D=E5=BA=A6=E7=B2=BE=E5=BA=A6=E6=8D=9F?= =?UTF-8?q?=E5=A4=B1=20(#6685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复兑换码额度精度损失(#6680) * fix(redemption): guard update data integrity --- .../redemptions-mutate-drawer.test.tsx | 439 ++++++++++++++++++ .../components/redemptions-mutate-drawer.tsx | 334 ++++++++----- .../redemption-codes/lib/redemption-form.ts | 9 +- web/src/lib/currency.ts | 26 +- web/src/lib/format.ts | 37 +- 5 files changed, 710 insertions(+), 135 deletions(-) create mode 100644 web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx diff --git a/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx b/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx new file mode 100644 index 000000000000..46008e102aa4 --- /dev/null +++ b/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx @@ -0,0 +1,439 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +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 support@quantumnous.com +*/ +import assert from 'node:assert/strict' + +import { Window } from 'happy-dom' + +import type { Redemption } from '../../types' + +// Use Bun's runner at runtime while reusing the Node test types installed here. +const bunTestModule = 'bun:test' +const { afterAll, afterEach, test } = (await import(bunTestModule)) as { + afterAll: typeof import('node:test').after + afterEach: typeof import('node:test').afterEach + test: typeof import('node:test').test +} + +const domWindow = new Window() +const domGlobals = [ + 'window', + 'document', + 'navigator', + 'HTMLElement', + 'HTMLButtonElement', + 'HTMLInputElement', + 'HTMLFormElement', + 'HTMLLabelElement', + 'HTMLFieldSetElement', + 'SVGElement', + 'Node', + 'Element', + 'Event', + 'KeyboardEvent', + 'PointerEvent', + 'MouseEvent', + 'FocusEvent', + 'CustomEvent', + 'MutationObserver', + 'ResizeObserver', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'getComputedStyle', +] as const + +for (const key of domGlobals) { + Object.defineProperty(globalThis, key, { + configurable: true, + value: domWindow[key], + }) +} + +const { act } = await import('react') +const { createRoot } = await import('react-dom/client') +const i18n = (await import('i18next')).default +const { I18nextProvider, initReactI18next } = await import('react-i18next') +const { Toaster, toast } = await import('sonner') +const { api } = await import('@/lib/api') +const { useSystemConfigStore } = await import('@/stores/system-config-store') +const { RedemptionsProvider } = await import('../redemptions-provider') +const { RedemptionsMutateDrawer } = await import('../redemptions-mutate-drawer') + +await i18n.use(initReactI18next).init({ + lng: 'en', + resources: { + en: { + translation: { + 'Failed to load': 'Failed to load', + 'Loading...': 'Loading...', + 'Save changes': 'Save changes', + 'Something went wrong!': 'Something went wrong!', + }, + }, + }, +}) + +const reactTestGlobals = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} +reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true + +type ApiMethod = (url: string, data?: unknown) => Promise<{ data: unknown }> +type MockableApi = { + get: ApiMethod + put: ApiMethod +} +type RenderedDrawer = { + host: HTMLDivElement + root: ReturnType +} +type CurrencyFixture = { + quotaDisplayType: 'USD' | 'CNY' + usdExchangeRate: number +} + +const apiClient = api as unknown as MockableApi +const originalGet = apiClient.get +const originalPut = apiClient.put +const originalConsoleLog = Reflect.get(console, 'log') +let renderedDrawer: RenderedDrawer | null = null + +function redemption(id: number, quota = 500001): Redemption { + return { + id, + user_id: 1, + name: `code-${id}`, + key: `key-${id}`, + status: 1, + quota, + created_time: 1, + redeemed_time: 0, + expired_time: 0, + used_user_id: 0, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, reject, resolve } +} + +function drawerTree(currentRow: Redemption) { + return ( + + + undefined} + /> + + + + ) +} + +async function renderDrawer( + currentRow: Redemption, + currency: CurrencyFixture = { + quotaDisplayType: 'USD', + usdExchangeRate: 1, + } +): Promise { + useSystemConfigStore.getState().setConfig({ + currency: { + displayInCurrency: true, + quotaDisplayType: currency.quotaDisplayType, + quotaPerUnit: 500000, + usdExchangeRate: currency.usdExchangeRate, + customCurrencySymbol: '¤', + customCurrencyExchangeRate: 1, + }, + }) + + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + renderedDrawer = { host, root } + + await act(async () => root.render(drawerTree(currentRow))) +} + +async function rerenderDrawer(currentRow: Redemption): Promise { + assert.ok(renderedDrawer) + await act(async () => renderedDrawer?.root.render(drawerTree(currentRow))) +} + +async function waitForCondition( + condition: () => boolean, + failureMessage: string +): Promise { + if (condition()) return + + await new Promise((resolve, reject) => { + const observer = new MutationObserver(() => { + if (!condition()) return + clearTimeout(timeoutId) + observer.disconnect() + resolve() + }) + const timeoutId = setTimeout(() => { + observer.disconnect() + reject(new Error(`${failureMessage}: ${document.body.textContent}`)) + }, 1500) + + observer.observe(document, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }) + }) +} + +function getSaveButton(): HTMLButtonElement { + const button = document.querySelector( + 'button[form="redemption-form"][type="submit"]' + ) + assert.ok(button) + return button +} + +function getControlByLabel(labelText: string): T { + const label = [...document.querySelectorAll('label')].find( + (candidate) => candidate.textContent?.trim() === labelText + ) + assert.ok(label, `Expected label "${labelText}"`) + assert.ok(label.htmlFor) + const control = + label.control ?? + label + .closest('[data-slot="form-item"]') + ?.querySelector('[data-slot="form-control"], input') + assert.ok(control) + return control as T +} + +async function changeInput(input: HTMLInputElement, value: string) { + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + domWindow.HTMLInputElement.prototype, + 'value' + )?.set + assert.ok(valueSetter) + valueSetter.call(input, value) + input.dispatchEvent( + new domWindow.Event('input', { bubbles: true }) as unknown as Event + ) + }) +} + +async function submitForm(): Promise { + const form = document.querySelector('#redemption-form') + assert.ok(form) + await act(async () => + form.dispatchEvent( + new domWindow.Event('submit', { + bubbles: true, + cancelable: true, + }) as unknown as Event + ) + ) +} + +async function waitForLoadedForm(): Promise { + await act(async () => + waitForCondition(() => { + const saveButton = getSaveButton() + return ( + saveButton.textContent?.includes('Save changes') === true && + !saveButton.disabled + ) + }, 'redemption drawer did not finish loading') + ) +} + +afterEach(async () => { + apiClient.get = originalGet + apiClient.put = originalPut + Reflect.set(console, 'log', originalConsoleLog) + toast.dismiss() + domWindow.localStorage.clear() + if (renderedDrawer) { + await act(async () => renderedDrawer?.root.unmount()) + renderedDrawer.host.remove() + renderedDrawer = null + } + document.body.replaceChildren() +}) + +afterAll(() => { + domWindow.close() +}) + +test('redemption drawer shows the reported CNY quota without floating-point noise', async () => { + const original = redemption(1, 13888889) + apiClient.get = async () => ({ data: { success: true, data: original } }) + + await renderDrawer(original, { + quotaDisplayType: 'CNY', + usdExchangeRate: 7.2, + }) + await waitForLoadedForm() + + assert.equal(getControlByLabel('Quota (CNY)').value, '200') +}) + +test('redemption drawer blocks updates and reports an error when loading rejects', async () => { + const updates: unknown[] = [] + Reflect.set(console, 'log', () => undefined) + apiClient.get = async () => { + throw new Error('network failure') + } + apiClient.put = async (_url, data) => { + updates.push(data) + return { data: { success: true } } + } + + await renderDrawer(redemption(1)) + await act(async () => + waitForCondition( + () => + document.body.textContent?.includes('Something went wrong!') === true, + 'load error toast was not shown' + ) + ) + + assert.equal(getSaveButton().disabled, true) + await submitForm() + assert.deepEqual(updates, []) +}) + +test('redemption drawer blocks updates and uses localized feedback for unsuccessful responses', async () => { + apiClient.get = async () => ({ + data: { success: false, message: 'raw server message' }, + }) + + await renderDrawer(redemption(1)) + await act(async () => + waitForCondition( + () => document.body.textContent?.includes('Failed to load') === true, + 'unsuccessful-load toast was not shown' + ) + ) + + assert.equal(getSaveButton().disabled, true) + assert.equal(document.body.textContent?.includes('raw server message'), false) +}) + +test('redemption drawer keeps the original quota when another field changes', async () => { + const original = redemption(1) + const updates: Array> = [] + apiClient.get = async () => ({ data: { success: true, data: original } }) + apiClient.put = async (_url, data) => { + assert.ok(data && typeof data === 'object') + updates.push(data as Record) + return { data: { success: true, data: original } } + } + + await renderDrawer(original) + await waitForLoadedForm() + assert.equal(getControlByLabel('Quota (USD)').value, '1') + + await changeInput(getControlByLabel('Name'), 'renamed') + await submitForm() + await act(async () => + waitForCondition(() => updates.length === 1, 'update was not submitted') + ) + + assert.equal(updates[0]?.name, 'renamed') + assert.equal(updates[0]?.quota, 500001) +}) + +test('redemption drawer recalculates quota when the quota field changes', async () => { + const original = redemption(1) + const updates: Array> = [] + apiClient.get = async () => ({ data: { success: true, data: original } }) + apiClient.put = async (_url, data) => { + assert.ok(data && typeof data === 'object') + updates.push(data as Record) + return { data: { success: true, data: original } } + } + + await renderDrawer(original) + await waitForLoadedForm() + await changeInput(getControlByLabel('Quota (USD)'), '2') + await submitForm() + await act(async () => + waitForCondition(() => updates.length === 1, 'update was not submitted') + ) + + assert.equal(updates[0]?.quota, 1000000) +}) + +test('redemption drawer ignores an older response after switching records', async () => { + const first = redemption(1, 500001) + const second = redemption(2, 1000001) + const firstRequest = deferred<{ data: unknown }>() + const secondRequest = deferred<{ data: unknown }>() + const requestedUrls: string[] = [] + const updates: Array> = [] + apiClient.get = (url) => { + requestedUrls.push(url) + if (url === '/api/redemption/1') return firstRequest.promise + if (url === '/api/redemption/2') return secondRequest.promise + throw new Error(`Unexpected GET ${url}`) + } + apiClient.put = async (_url, data) => { + assert.ok(data && typeof data === 'object') + updates.push(data as Record) + return { data: { success: true, data: second } } + } + + await renderDrawer(first) + await rerenderDrawer(second) + await act(async () => + waitForCondition( + () => requestedUrls.includes('/api/redemption/2'), + 'second redemption was not requested' + ) + ) + await act(async () => + secondRequest.resolve({ data: { success: true, data: second } }) + ) + await waitForLoadedForm() + + await act(async () => + firstRequest.resolve({ data: { success: true, data: first } }) + ) + assert.equal(getControlByLabel('Name').value, 'code-2') + + await changeInput(getControlByLabel('Name'), 'second') + await submitForm() + await act(async () => + waitForCondition(() => updates.length === 1, 'update was not submitted') + ) + + assert.equal(updates[0]?.id, 2) + assert.equal(updates[0]?.quota, 1000001) +}) diff --git a/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index 47f7a387c34c..b8f455e6381c 100644 --- a/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -51,7 +51,12 @@ import { SheetTitle, } from '@/components/ui/sheet' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' -import { formatQuota, parseQuotaFromDollars } from '@/lib/format' +import { + formatQuota, + getEditableQuotaStep, + parseQuotaFromDollars, +} from '@/lib/format' +import { handleServerError } from '@/lib/handle-server-error' import { addTimeToDate } from '@/lib/time' import { createRedemption, updateRedemption, getRedemption } from '../api' @@ -63,7 +68,7 @@ import { transformFormDataToPayload, transformRedemptionToFormDefaults, } from '../lib' -import { type Redemption } from '../types' +import type { Redemption } from '../types' import { useRedemptions } from './redemptions-provider' type RedemptionsMutateDrawerProps = { @@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({ }: RedemptionsMutateDrawerProps) { const { t } = useTranslation() const isUpdate = !!currentRow + const redemptionId = currentRow?.id const { triggerRefresh } = useRedemptions() const [isSubmitting, setIsSubmitting] = useState(false) + const [redemptionLoadState, setRedemptionLoadState] = useState< + 'idle' | 'loading' | 'ready' | 'error' + >('idle') + const [loadedRedemption, setLoadedRedemption] = useState( + null + ) const form = useForm({ resolver: zodResolver(getRedemptionFormSchema(t)), @@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({ // Load existing data when updating useEffect(() => { - if (open && isUpdate && currentRow) { - // For update, fetch fresh data - getRedemption(currentRow.id).then((result) => { - if (result.success && result.data) { - form.reset(transformRedemptionToFormDefaults(result.data)) + if (!open) { + setRedemptionLoadState('idle') + setLoadedRedemption(null) + return + } + + if (!isUpdate || redemptionId === undefined) { + form.reset(REDEMPTION_FORM_DEFAULT_VALUES) + setRedemptionLoadState('ready') + setLoadedRedemption(null) + return + } + + let ignoreResult = false + + form.reset(REDEMPTION_FORM_DEFAULT_VALUES) + setRedemptionLoadState('loading') + setLoadedRedemption(null) + + void getRedemption(redemptionId) + .then((result) => { + if (ignoreResult) return + + if ( + !result.success || + !result.data || + result.data.id !== redemptionId + ) { + setRedemptionLoadState('error') + toast.error(t('Failed to load')) + return } + + form.reset(transformRedemptionToFormDefaults(result.data)) + setLoadedRedemption(result.data) + setRedemptionLoadState('ready') }) - } else if (open && !isUpdate) { - // For create, reset to defaults - form.reset(REDEMPTION_FORM_DEFAULT_VALUES) + .catch((error: unknown) => { + if (ignoreResult) return + + setRedemptionLoadState('error') + handleServerError(error) + }) + + return () => { + ignoreResult = true } - }, [open, isUpdate, currentRow, form]) + }, [open, isUpdate, redemptionId, form, t]) + + const isUpdateReady = + !isUpdate || + (redemptionLoadState === 'ready' && loadedRedemption?.id === redemptionId) + const isLoadingRedemption = redemptionLoadState === 'loading' const onSubmit = async (data: RedemptionFormValues) => { + if (isUpdate && (!currentRow || !loadedRedemption || !isUpdateReady)) { + return + } + setIsSubmitting(true) try { const basePayload = transformFormDataToPayload(data) - if (isUpdate && currentRow) { + if (isUpdate && currentRow && loadedRedemption) { + const quota = form.getFieldState('quota_dollars').isDirty + ? basePayload.quota + : loadedRedemption.quota const result = await updateRedemption({ ...basePayload, + quota, id: currentRow.id, }) if (result.success) { @@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({ const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() const tokensOnly = currencyMeta.kind === 'tokens' + const quotaStep = getEditableQuotaStep() const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) const quotaPlaceholder = tokensOnly ? t('Enter quota in tokens') : t('Enter quota in {{currency}}', { currency: currencyLabel }) + let submitButtonLabel = t('Save changes') + if (isLoadingRedemption) { + submitButtonLabel = t('Loading...') + } else if (isSubmitting) { + submitButtonLabel = t('Saving...') + } return ( - - ( - - {t('Name')} - - - - - {t('Name for this redemption code (1-20 characters)')} - - - - )} - /> - - ( - - {quotaLabel} - - - field.onChange(parseFloat(e.target.value) || 0) - } - /> - - - {tokensOnly - ? t('Enter the quota amount in tokens') - : t('Enter the quota amount in {{currency}}', { - currency: currencyLabel, - })} - - - - )} - /> - - ( - - {t('Expiration Time')} -
+
+ + ( + + {t('Name')} - + -
- - - - -
-
- - {t('Leave empty for never expires')} - - -
- )} - /> + + {t('Name for this redemption code (1-20 characters)')} + + + + )} + /> - {!isUpdate && ( ( - {t('Quantity')} + {quotaLabel} - field.onChange(parseInt(e.target.value, 10) || 1) + field.onChange( + Number.parseFloat(e.target.value) || 0 + ) } /> - {t('Create multiple redemption codes at once (1-100)')} + {tokensOnly + ? t('Enter the quota amount in tokens') + : t('Enter the quota amount in {{currency}}', { + currency: currencyLabel, + })} )} /> - )} -
+ + ( + + {t('Expiration Time')} +
+ + + +
+ + + + +
+
+ + {t('Leave empty for never expires')} + + +
+ )} + /> + + {!isUpdate && ( + ( + + {t('Quantity')} + + + field.onChange( + Number.parseInt(e.target.value, 10) || 1 + ) + } + /> + + + {t( + 'Create multiple redemption codes at once (1-100)' + )} + + + + )} + /> + )} + + }> {t('Close')} - diff --git a/web/src/features/redemption-codes/lib/redemption-form.ts b/web/src/features/redemption-codes/lib/redemption-form.ts index fa4c30597ddd..a8aa9d1ae27e 100644 --- a/web/src/features/redemption-codes/lib/redemption-form.ts +++ b/web/src/features/redemption-codes/lib/redemption-form.ts @@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com import type { TFunction } from 'i18next' import { z } from 'zod' -import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format' +import { + parseQuotaFromDollars, + quotaUnitsToEditableAmount, +} from '@/lib/format' import { REDEMPTION_VALIDATION, getRedemptionFormErrorMessages, } from '../constants' -import { type RedemptionFormData, type Redemption } from '../types' +import type { RedemptionFormData, Redemption } from '../types' // ============================================================================ // Form Schema (use getRedemptionFormSchema(t) in components for i18n messages) @@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults( ): RedemptionFormValues { return { name: redemption.name, - quota_dollars: quotaUnitsToDollars(redemption.quota), + quota_dollars: quotaUnitsToEditableAmount(redemption.quota), expired_time: redemption.expired_time > 0 ? new Date(redemption.expired_time * 1000) diff --git a/web/src/lib/currency.ts b/web/src/lib/currency.ts index ae5729615d29..15a3d345d05c 100644 --- a/web/src/lib/currency.ts +++ b/web/src/lib/currency.ts @@ -244,6 +244,23 @@ function mergeOptions( } } +function getFractionDigits( + value: number, + digitsLarge: number, + digitsSmall: number +): number { + return Math.abs(value) >= 1 ? digitsLarge : digitsSmall +} + +/** Return the configured fraction digits for a plain currency value. */ +export function getCurrencyFractionDigits( + value: number, + options?: CurrencyFormatOptions +): number { + const merged = mergeOptions(options) + return getFractionDigits(value, merged.digitsLarge, merged.digitsSmall) +} + function removeTrailingZeros(str: string): string { if (!str.includes('.')) return str return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '') @@ -261,7 +278,7 @@ function formatNumberWithSuffix( return `${removeTrailingZeros(result.toFixed(1))}k` } - const digits = abs >= 1 ? digitsLarge : digitsSmall + const digits = getFractionDigits(value, digitsLarge, digitsSmall) return removeTrailingZeros(value.toFixed(digits)) } @@ -300,8 +317,11 @@ function formatCurrencyValue( ) } - const digits = - Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall + const digits = getFractionDigits( + value, + options.digitsLarge, + options.digitsSmall + ) const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero) if (meta.kind === 'currency') { diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 64aa7a2db22b..83829fc3a0ec 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -22,6 +22,7 @@ import { formatCurrencyFromUSD, formatQuotaWithCurrency, getCurrencyDisplay, + getCurrencyFractionDigits, } from './currency' // ============================================================================ @@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number { */ export function quotaUnitsToDollars(units: number): number { const { config, meta } = getCurrencyDisplay() + return quotaUnitsToDisplayAmount(units, config.quotaPerUnit, meta) +} +function quotaUnitsToDisplayAmount( + units: number, + quotaPerUnit: number, + meta: ReturnType['meta'] +): number { if (meta.kind === 'tokens') { return units } - const usdAmount = units / config.quotaPerUnit - const exchangeRate = - meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1 + return (units / quotaPerUnit) * meta.exchangeRate +} + +/** + * Convert quota units to a plain number suitable for an editable input. + * Uses the same precision as quota list formatting without symbols or suffixes. + */ +export function quotaUnitsToEditableAmount(units: number): number { + const { config, meta } = getCurrencyDisplay() + const amount = quotaUnitsToDisplayAmount(units, config.quotaPerUnit, meta) + + if (meta.kind === 'tokens') { + return Math.round(amount) + } + + return Number(amount.toFixed(getCurrencyFractionDigits(amount))) +} + +/** Return the input step matching the configured editable quota precision. */ +export function getEditableQuotaStep(): number { + const { meta } = getCurrencyDisplay() + if (meta.kind === 'tokens') { + return 1 + } - return usdAmount * exchangeRate + return 10 ** -getCurrencyFractionDigits(0) } // ============================================================================ From 5c3abffe8572aa8a49f15c3916707d2019d66af4 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 7 Aug 2026 17:40:24 +0800 Subject: [PATCH 08/18] CI: enhance release synchronization workflow with optional file syncing --- .github/workflows/sync-release-to-gitcode.yml | 264 +++++++++--------- 1 file changed, 133 insertions(+), 131 deletions(-) diff --git a/.github/workflows/sync-release-to-gitcode.yml b/.github/workflows/sync-release-to-gitcode.yml index 96aff722d538..d311f499eece 100644 --- a/.github/workflows/sync-release-to-gitcode.yml +++ b/.github/workflows/sync-release-to-gitcode.yml @@ -5,24 +5,25 @@ permissions: contents: read on: - push: - tags: - - '*' - - '!*-alpha*' workflow_dispatch: inputs: tag_name: description: GitHub release tag to sync required: true type: string + sync_files: + description: Sync GitHub release files to GitCode + required: false + default: false + type: boolean concurrency: - group: gitcode-release-${{ inputs.tag_name || github.ref_name }} + group: gitcode-release-${{ inputs.tag_name }} cancel-in-progress: false jobs: - prepare-release-assets: - name: Prepare GitHub release assets + prepare-release: + name: Create or update GitCode release if: ${{ vars.GITCODE_REPOSITORY != '' }} runs-on: ubuntu-latest timeout-minutes: 90 @@ -30,80 +31,125 @@ jobs: release_tag: ${{ steps.release.outputs.tag }} release_body: ${{ steps.release.outputs.body }} release_prerelease: ${{ steps.release.outputs.prerelease }} - bootstrap_asset: ${{ steps.release.outputs.bootstrap_asset }} - release_asset_matrix: ${{ steps.release.outputs.asset_matrix }} - has_bootstrap_asset: ${{ steps.release.outputs.has_bootstrap_asset }} - has_matrix_assets: ${{ steps.release.outputs.has_matrix_assets }} + release_asset_matrix: ${{ steps.assets.outputs.matrix }} + has_release_assets: ${{ steps.assets.outputs.has_assets }} steps: - - name: Wait for GitHub release workflows - if: ${{ github.event_name == 'push' }} + - name: Create or update GitCode release + id: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }} + GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} + RELEASE_TAG: ${{ inputs.tag_name }} run: | set -euo pipefail - wait_for_workflow() { - local workflow="$1" - local label="$2" - local last_state="" - - for attempt in $(seq 1 180); do - run_json="$( - gh run list \ - --repo "$GITHUB_REPOSITORY" \ - --workflow "$workflow" \ - --event push \ - --commit "$GITHUB_SHA" \ - --limit 1 \ - --json conclusion,status,url \ - --jq '.[0] // {}' - )" - status="$(jq -r '.status // empty' <<< "$run_json")" - conclusion="$(jq -r '.conclusion // empty' <<< "$run_json")" - state="${status:-not-found}/${conclusion:-pending}" - - if [[ "$state" != "$last_state" ]]; then - echo "$label: $state" - last_state="$state" - fi - - if [[ "$status" == "completed" ]]; then - if [[ "$conclusion" == "success" ]]; then - return 0 - fi - echo "::error::$label finished with conclusion: $conclusion" - return 1 - fi + release_json="$( + gh release view "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json body,isPrerelease + )" + release_body="$(jq -r '.body // ""' <<< "$release_json" | sed "s/'/’/g")" + release_prerelease="$(jq -r '.isPrerelease' <<< "$release_json")" + if [[ "$release_prerelease" == "true" ]]; then + release_status=pre + else + release_status=latest + fi - sleep 20 - done + gitcode_release_json="$(mktemp)" + gitcode_release_status="$( + curl -L -sS -o "$gitcode_release_json" -w "%{http_code}" \ + -H "PRIVATE-TOKEN: $GITCODE_TOKEN" \ + -H "Accept: application/json" \ + "https://api.gitcode.com/api/v5/repos/$GITCODE_REPOSITORY/releases/tags/$RELEASE_TAG" + )" - echo "::error::Timed out waiting for $label" - return 1 - } + request_json="$(mktemp)" + case "$gitcode_release_status" in + 200) + request_method=PATCH + request_url="https://api.gitcode.com/api/v5/repos/$GITCODE_REPOSITORY/releases/$RELEASE_TAG" + request_action=update + success_action=updated + jq -n \ + --arg tag_name "$RELEASE_TAG" \ + --arg name "$RELEASE_TAG" \ + --arg body "$release_body" \ + --arg release_status "$release_status" \ + '{ + tag_name: $tag_name, + name: $name, + body: $body, + release_status: $release_status + }' > "$request_json" + ;; + 404) + if ! git ls-remote --exit-code --tags \ + "https://gitcode.com/$GITCODE_REPOSITORY.git" \ + "refs/tags/$RELEASE_TAG" > /dev/null; then + echo "::error::Tag $RELEASE_TAG has not been mirrored to GitCode yet. Retry this workflow after the tag appears on GitCode." + exit 1 + fi - wait_for_workflow release.yml "Backend release" + request_method=POST + request_url="https://api.gitcode.com/api/v5/repos/$GITCODE_REPOSITORY/releases" + request_action=create + success_action=created + jq -n \ + --arg tag_name "$RELEASE_TAG" \ + --arg name "$RELEASE_TAG" \ + --arg body "$release_body" \ + --arg release_status "$release_status" \ + '{ + tag_name: $tag_name, + name: $name, + body: $body, + release_status: $release_status + }' > "$request_json" + ;; + *) + echo "::error::Failed to inspect GitCode release. Response code: $gitcode_release_status" + cat "$gitcode_release_json" + exit 1 + ;; + esac - if [[ "$GITHUB_REF_NAME" != *-* ]]; then - wait_for_workflow electron-build.yml "Electron release" + gitcode_response_json="$(mktemp)" + request_status="$( + curl -L -sS -o "$gitcode_response_json" -w "%{http_code}" \ + -X "$request_method" \ + -H "PRIVATE-TOKEN: $GITCODE_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data-binary "@$request_json" \ + "$request_url" + )" + if [[ "$request_status" != "200" ]]; then + echo "::error::Failed to $request_action GitCode release. Response code: $request_status" + cat "$gitcode_response_json" + exit 1 fi + echo "GitCode release $success_action successfully" + + delimiter="release-body-$(openssl rand -hex 16)" + { + echo "tag=$RELEASE_TAG" + echo "body<<$delimiter" + echo "$release_body" + echo "$delimiter" + echo "prerelease=$release_prerelease" + } >> "$GITHUB_OUTPUT" - name: Download GitHub release assets - id: release + if: ${{ inputs.sync_files }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }} - GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} - RELEASE_TAG: ${{ inputs.tag_name || github.ref_name }} + RELEASE_TAG: ${{ inputs.tag_name }} run: | set -euo pipefail mkdir release-assets - release_json="$( - gh release view "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --json body,isPrerelease - )" gh release download "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --dir release-assets @@ -128,6 +174,16 @@ jobs: find release-assets -maxdepth 1 -type f -print | sort + - name: Prepare GitCode release assets + id: assets + if: ${{ inputs.sync_files }} + env: + GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }} + GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} + RELEASE_TAG: ${{ inputs.tag_name }} + run: | + set -euo pipefail + local_assets="$( find release-assets -maxdepth 1 -type f -printf '%s\t%f\n' \ | sort -n -k1,1 \ @@ -142,23 +198,13 @@ jobs: -H "Accept: application/json" \ "https://api.gitcode.com/api/v5/repos/$GITCODE_REPOSITORY/releases/tags/$RELEASE_TAG" )" + if [[ "$gitcode_release_status" != "200" ]]; then + echo "::error::Failed to inspect GitCode release assets. Response code: $gitcode_release_status" + cat "$gitcode_release_json" + exit 1 + fi - case "$gitcode_release_status" in - 200) - release_exists=true - existing_assets="$(jq -c '[.assets[]?.name]' "$gitcode_release_json")" - ;; - 404) - release_exists=false - existing_assets='[]' - ;; - *) - echo "::error::Failed to inspect GitCode release. Response code: $gitcode_release_status" - cat "$gitcode_release_json" - exit 1 - ;; - esac - + existing_assets="$(jq -c '[.assets[]?.name]' "$gitcode_release_json")" missing_assets="$( jq -cn \ --argjson local_assets "$local_assets" \ @@ -170,37 +216,11 @@ jobs: --argjson missing_assets "$missing_assets" \ '$local_assets - $missing_assets | .[] | "Skipping existing GitCode asset: \(.)"' - if [[ "$release_exists" == "false" ]]; then - bootstrap_asset="$(jq -r '.[0] // ""' <<< "$missing_assets")" - asset_matrix="$(jq -c '.[1:]' <<< "$missing_assets")" - else - bootstrap_asset="" - asset_matrix="$missing_assets" - fi - - if [[ -n "$bootstrap_asset" ]]; then - has_bootstrap_asset=true - else - has_bootstrap_asset=false - fi - has_matrix_assets="$(jq -r 'length > 0' <<< "$asset_matrix")" - - delimiter="release-body-$(openssl rand -hex 16)" - { - echo "tag=$RELEASE_TAG" - echo "bootstrap_asset=$bootstrap_asset" - echo "asset_matrix=$asset_matrix" - echo "has_bootstrap_asset=$has_bootstrap_asset" - echo "has_matrix_assets=$has_matrix_assets" - echo "body<<$delimiter" - # sync_to_gitcode embeds this input in a single-quoted shell string. - # Replace ASCII apostrophes so release notes cannot break its script. - jq -r '.body // ""' <<< "$release_json" | sed "s/'/’/g" - echo "$delimiter" - echo "prerelease=$(jq -r '.isPrerelease' <<< "$release_json")" - } >> "$GITHUB_OUTPUT" + echo "matrix=$missing_assets" >> "$GITHUB_OUTPUT" + echo "has_assets=$(jq -r 'length > 0' <<< "$missing_assets")" >> "$GITHUB_OUTPUT" - name: Upload release assets for GitCode + if: ${{ inputs.sync_files && steps.assets.outputs.has_assets == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: gitcode-release-assets @@ -208,41 +228,23 @@ jobs: if-no-files-found: error retention-days: 1 - gitcode-release-bootstrap: - name: Create GitCode release - needs: prepare-release-assets - if: ${{ vars.GITCODE_REPOSITORY != '' && needs.prepare-release-assets.outputs.has_bootstrap_asset == 'true' }} - uses: nvdacn/sync_to_gitcode/.github/workflows/CreateReleaseOnGitCode.yaml@18b70112d0e62260bc54085028e5510bbc6323b2 - with: - artifact_name: gitcode-release-assets - gitcode_repository: ${{ vars.GITCODE_REPOSITORY }} - default_branch: main - tag_name: ${{ needs.prepare-release-assets.outputs.release_tag }} - body: ${{ needs.prepare-release-assets.outputs.release_body }} - prerelease: ${{ needs.prepare-release-assets.outputs.release_prerelease == 'true' }} - file_name: ${{ needs.prepare-release-assets.outputs.bootstrap_asset }} - secrets: - GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} - gitcode-release-assets: name: Publish GitCode asset (${{ matrix.file_name }}) - needs: - - prepare-release-assets - - gitcode-release-bootstrap - if: ${{ always() && vars.GITCODE_REPOSITORY != '' && needs.prepare-release-assets.result == 'success' && (needs.gitcode-release-bootstrap.result == 'success' || needs.gitcode-release-bootstrap.result == 'skipped') && needs.prepare-release-assets.outputs.has_matrix_assets == 'true' }} + needs: prepare-release + if: ${{ inputs.sync_files && needs.prepare-release.result == 'success' && needs.prepare-release.outputs.has_release_assets == 'true' }} strategy: fail-fast: false max-parallel: 4 matrix: - file_name: ${{ fromJSON(needs.prepare-release-assets.outputs.release_asset_matrix) }} + file_name: ${{ fromJSON(needs.prepare-release.outputs.release_asset_matrix) }} uses: nvdacn/sync_to_gitcode/.github/workflows/CreateReleaseOnGitCode.yaml@18b70112d0e62260bc54085028e5510bbc6323b2 with: artifact_name: gitcode-release-assets gitcode_repository: ${{ vars.GITCODE_REPOSITORY }} default_branch: main - tag_name: ${{ needs.prepare-release-assets.outputs.release_tag }} - body: ${{ needs.prepare-release-assets.outputs.release_body }} - prerelease: ${{ needs.prepare-release-assets.outputs.release_prerelease == 'true' }} + tag_name: ${{ needs.prepare-release.outputs.release_tag }} + body: ${{ needs.prepare-release.outputs.release_body }} + prerelease: ${{ needs.prepare-release.outputs.release_prerelease == 'true' }} file_name: ${{ matrix.file_name }} secrets: GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} From 2399de97daf6ac76e5378a7c7c244ff0628a8186 Mon Sep 17 00:00:00 2001 From: ENCHIGO <38551565+ENCHIGO@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:54:00 +0800 Subject: [PATCH 09/18] fix(ali): stop injecting top_p into requests that omit it (#6674) --- relay/channel/ali/text.go | 18 ++++++++--- relay/channel/ali/text_test.go | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 relay/channel/ali/text_test.go diff --git a/relay/channel/ali/text.go b/relay/channel/ali/text.go index eea13129756e..8e8f4c7cc1f6 100644 --- a/relay/channel/ali/text.go +++ b/relay/channel/ali/text.go @@ -18,11 +18,19 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest, upstreamModelName strin request.ThinkingBudget = nil } - topP := lo.FromPtrOr(request.TopP, 0) - if topP >= 1 { - request.TopP = lo.ToPtr(0.999) - } else if topP <= 0 { - request.TopP = lo.ToPtr(0.001) + // DashScope rejects top_p at the 0 and 1 boundaries, so an explicit value is + // clamped into the open interval. The clamp stays at two decimals because + // some models on the platform reject a third decimal with + // "top_p参数非法:限制小数点[2]位". + // + // A request that omits top_p is left untouched: injecting a value would + // silently replace the model's own default with near-greedy decoding. + if request.TopP != nil { + if *request.TopP >= 1 { + request.TopP = lo.ToPtr(0.99) + } else if *request.TopP <= 0 { + request.TopP = lo.ToPtr(0.01) + } } return &request } diff --git a/relay/channel/ali/text_test.go b/relay/channel/ali/text_test.go new file mode 100644 index 000000000000..dc37e4ed2e26 --- /dev/null +++ b/relay/channel/ali/text_test.go @@ -0,0 +1,59 @@ +package ali + +import ( + "testing" + + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func TestRequestOpenAI2AliTopP(t *testing.T) { + tests := []struct { + name string + topP *float64 + want *float64 + }{ + { + name: "omitted top_p is not injected", + topP: nil, + want: nil, + }, + { + name: "in-range top_p is preserved", + topP: lo.ToPtr(0.8), + want: lo.ToPtr(0.8), + }, + { + name: "top_p of 1 is clamped to two decimals", + topP: lo.ToPtr(1.0), + want: lo.ToPtr(0.99), + }, + { + name: "top_p above 1 is clamped to two decimals", + topP: lo.ToPtr(1.5), + want: lo.ToPtr(0.99), + }, + { + name: "top_p of 0 is clamped to two decimals", + topP: lo.ToPtr(0.0), + want: lo.ToPtr(0.01), + }, + { + name: "negative top_p is clamped to two decimals", + topP: lo.ToPtr(-0.3), + want: lo.ToPtr(0.01), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := requestOpenAI2Ali(dto.GeneralOpenAIRequest{ + Model: "qwen-plus", + TopP: tt.topP, + }, "qwen-plus") + + assert.Equal(t, tt.want, got.TopP) + }) + } +} From 823e26304a396854ace30b52b98ec497c2dd9c36 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:31:29 +0800 Subject: [PATCH 10/18] fix(channels): classify Qwen TTS models correctly (#6711) --- web/src/features/channels/lib/model-categories.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/features/channels/lib/model-categories.ts b/web/src/features/channels/lib/model-categories.ts index 6d49cf65db4e..5db0dd738219 100644 --- a/web/src/features/channels/lib/model-categories.ts +++ b/web/src/features/channels/lib/model-categories.ts @@ -38,7 +38,6 @@ const MODEL_CATEGORY_RULES: readonly ModelCategoryRule[] = [ 'codex-', 'dall-e-', 'whisper-', - 'tts-', 'omni-moderation-', 'text-moderation-', 'text-embedding-ada-', @@ -51,7 +50,7 @@ const MODEL_CATEGORY_RULES: readonly ModelCategoryRule[] = [ 'computer-use-preview', 'sora', ], - pattern: /(?:^|[/.:])o(?:1|3|4)(?=$|[-.:])/, + pattern: /(?:^|[/.:])(?:o(?:1|3|4)(?=$|[-.:])|tts-)/, }, { name: 'Anthropic', keywords: ['anthropic', 'claude'] }, { From 5d3423bec13f6da2498bdc5b288c9ee2507fd3ef Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:47:44 +0800 Subject: [PATCH 11/18] feat(channels): add auto-disable-only channel test mode (#6728) --- controller/channel-test.go | 3 ++ controller/channel_test_internal_test.go | 18 +++++++ setting/operation_setting/monitor_setting.go | 5 +- .../operation_setting/monitor_setting_test.go | 14 ++++++ .../models/routing-reliability-section.tsx | 49 +++++++++++++++---- web/src/features/system-settings/types.ts | 5 +- web/src/i18n/locales/en.json | 9 ++++ web/src/i18n/locales/fr.json | 9 ++++ web/src/i18n/locales/ja.json | 9 ++++ web/src/i18n/locales/ru.json | 9 ++++ web/src/i18n/locales/vi.json | 9 ++++ web/src/i18n/locales/zh-TW.json | 9 ++++ web/src/i18n/locales/zh.json | 9 ++++ 13 files changed, 146 insertions(+), 11 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index f494af0431f6..a13ed5d607b2 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -1049,6 +1049,9 @@ func selectChannelsForAutomaticTest(channels []*model.Channel, mode string) []*m if channel.Status == common.ChannelStatusManuallyDisabled { continue } + if mode == operation_setting.ChannelTestModeAutoBanOnly && !channel.GetAutoBan() { + continue + } if mode == operation_setting.ChannelTestModePassiveRecovery && channel.Status != common.ChannelStatusAutoDisabled { continue } diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index abbd2d237070..fa69f852ae2d 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -312,6 +312,24 @@ func TestSelectChannelsForAutomaticTestScheduledSkipsManualDisabled(t *testing.T require.Equal(t, 2, selected[1].Id) } +func TestSelectChannelsForAutomaticTestAutoBanOnlyUsesEligibleChannels(t *testing.T) { + autoBanEnabled := 1 + autoBanDisabled := 0 + channels := []*model.Channel{ + {Id: 1, Status: common.ChannelStatusEnabled, AutoBan: &autoBanEnabled}, + {Id: 2, Status: common.ChannelStatusEnabled, AutoBan: &autoBanDisabled}, + {Id: 3, Status: common.ChannelStatusAutoDisabled, AutoBan: &autoBanEnabled}, + {Id: 4, Status: common.ChannelStatusManuallyDisabled, AutoBan: &autoBanEnabled}, + {Id: 5, Status: common.ChannelStatusEnabled}, + } + + selected := selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModeAutoBanOnly) + + require.Len(t, selected, 2) + require.Equal(t, 1, selected[0].Id) + require.Equal(t, 3, selected[1].Id) +} + func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) { db := setupModelListControllerTestDB(t) require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{})) diff --git a/setting/operation_setting/monitor_setting.go b/setting/operation_setting/monitor_setting.go index 8593d8349a4c..a88087f21569 100644 --- a/setting/operation_setting/monitor_setting.go +++ b/setting/operation_setting/monitor_setting.go @@ -15,6 +15,7 @@ type MonitorSetting struct { const ( ChannelTestModeScheduledAll = "scheduled_all" + ChannelTestModeAutoBanOnly = "auto_ban_only" ChannelTestModePassiveRecovery = "passive_recovery" ) @@ -45,7 +46,9 @@ func GetMonitorSetting() *MonitorSetting { monitorSetting.AutoTestChannelEnabled = parsed } } - if monitorSetting.ChannelTestMode != ChannelTestModePassiveRecovery { + switch monitorSetting.ChannelTestMode { + case ChannelTestModeAutoBanOnly, ChannelTestModePassiveRecovery: + default: monitorSetting.ChannelTestMode = ChannelTestModeScheduledAll } return &monitorSetting diff --git a/setting/operation_setting/monitor_setting_test.go b/setting/operation_setting/monitor_setting_test.go index 7aef7eaaa9cc..c31023e84b8b 100644 --- a/setting/operation_setting/monitor_setting_test.go +++ b/setting/operation_setting/monitor_setting_test.go @@ -41,3 +41,17 @@ func TestGetMonitorSetting_ChannelTestEnabledEnvCanEnableDisabledConfig(t *testi assert.True(t, setting.AutoTestChannelEnabled) assert.Equal(t, float64(12), setting.AutoTestChannelMinutes) } + +func TestGetMonitorSettingPreservesAutoBanOnlyMode(t *testing.T) { + orig := monitorSetting + t.Cleanup(func() { monitorSetting = orig }) + + t.Setenv("CHANNEL_TEST_ENABLED", "") + t.Setenv("CHANNEL_TEST_FREQUENCY", "") + monitorSetting = MonitorSetting{ChannelTestMode: ChannelTestModeAutoBanOnly} + + setting := GetMonitorSetting() + + require.NotNil(t, setting) + assert.Equal(t, ChannelTestModeAutoBanOnly, setting.ChannelTestMode) +} diff --git a/web/src/features/system-settings/models/routing-reliability-section.tsx b/web/src/features/system-settings/models/routing-reliability-section.tsx index efc8092a1c43..1b8527ec02d9 100644 --- a/web/src/features/system-settings/models/routing-reliability-section.tsx +++ b/web/src/features/system-settings/models/routing-reliability-section.tsx @@ -63,7 +63,11 @@ const numericString = z.string().refine((value) => { return !Number.isNaN(Number(trimmed)) && Number(trimmed) >= 0 }, 'Enter a non-negative number or leave empty') -const channelTestModes = ['scheduled_all', 'passive_recovery'] as const +const channelTestModes = [ + 'scheduled_all', + 'auto_ban_only', + 'passive_recovery', +] as const type ChannelTestMode = (typeof channelTestModes)[number] const routingReliabilitySchema = z @@ -148,7 +152,10 @@ type NormalizedRoutingReliabilityValues = { } function normalizeChannelTestMode(value?: string): ChannelTestMode { - return value === 'passive_recovery' ? 'passive_recovery' : 'scheduled_all' + if (value === 'auto_ban_only' || value === 'passive_recovery') { + return value + } + return 'scheduled_all' } const buildFormDefaults = ( @@ -250,6 +257,23 @@ export function RoutingReliabilitySection({ const autoDisableStatusCodes = form.watch('AutomaticDisableStatusCodes') const autoRetryStatusCodes = form.watch('AutomaticRetryStatusCodes') const channelTestMode = form.watch('monitor_setting.channel_test_mode') + let channelTestModeDescription: string + switch (channelTestMode) { + case 'auto_ban_only': + channelTestModeDescription = t( + 'Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.' + ) + break + case 'passive_recovery': + channelTestModeDescription = t( + 'Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.' + ) + break + default: + channelTestModeDescription = t( + 'Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.' + ) + } const autoDisableParsed = useMemo( () => parseHttpStatusCodeRules(autoDisableStatusCodes), [autoDisableStatusCodes] @@ -391,11 +415,17 @@ export function RoutingReliabilitySection({ items={[ { value: 'scheduled_all', - label: t('Scheduled full test'), + label: t('Actively check all channels'), + }, + { + value: 'auto_ban_only', + label: t( + 'Actively check auto-disable-enabled channels' + ), }, { value: 'passive_recovery', - label: t('Passive recovery only'), + label: t('Check channels awaiting recovery only'), }, ]} value={field.value} @@ -409,18 +439,19 @@ export function RoutingReliabilitySection({ - {t('Scheduled full test')} + {t('Actively check all channels')} + + + {t('Actively check auto-disable-enabled channels')} - {t('Passive recovery only')} + {t('Check channels awaiting recovery only')} - {t( - 'Scheduled full test probes non-manually-disabled channels; passive recovery only checks auto-disabled channels after real request failures.' - )} + {channelTestModeDescription} diff --git a/web/src/features/system-settings/types.ts b/web/src/features/system-settings/types.ts index 6bb6f2dbc436..d9445e54cc60 100644 --- a/web/src/features/system-settings/types.ts +++ b/web/src/features/system-settings/types.ts @@ -235,7 +235,10 @@ export type ModelSettings = { AutomaticRetryStatusCodes: string 'monitor_setting.auto_test_channel_enabled': boolean 'monitor_setting.auto_test_channel_minutes': number - 'monitor_setting.channel_test_mode': 'scheduled_all' | 'passive_recovery' + 'monitor_setting.channel_test_mode': + | 'scheduled_all' + | 'auto_ban_only' + | 'passive_recovery' 'channel_affinity_setting.enabled': boolean 'channel_affinity_setting.switch_on_success': boolean 'channel_affinity_setting.keep_on_channel_disabled': boolean diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b48096a7fcfa..ce02e9ce685d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -151,6 +151,8 @@ "Active models": "Active models", "Active Tasks": "Active Tasks", "active users": "active users", + "Actively check all channels": "Actively check all channels", + "Actively check auto-disable-enabled channels": "Actively check auto-disable-enabled channels", "Actual Amount": "Actual Amount", "Actual Model": "Actual Model", "Actual Model:": "Actual Model:", @@ -512,6 +514,8 @@ "Auto Sync Upstream Models": "Auto Sync Upstream Models", "Auto-disable rules": "Auto-disable rules", "Auto-disable status codes": "Auto-disable status codes", + "Auto-disable-enabled channels only": "Auto-disable-enabled channels only", + "Auto-disable-enabled mode probes non-manually-disabled channels with auto-disable enabled.": "Auto-disable-enabled mode probes non-manually-disabled channels with auto-disable enabled.", "Auto-discover": "Auto-discover", "Auto-discovers endpoints from the provider": "Auto-discovers endpoints from the provider", "Auto-fill when one field exists and another is missing": "Auto-fill when one field exists and another is missing", @@ -783,6 +787,7 @@ "Chat session management": "Chat session management", "ChatCompletions -> Responses Compatibility": "ChatCompletions -> Responses Compatibility", "ChatGPT Subscription (Codex)": "ChatGPT Subscription (Codex)", + "Check channels awaiting recovery only": "Check channels awaiting recovery only", "Check for updates": "Check for updates", "Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards", "Check in now": "Check in now", @@ -1437,6 +1442,7 @@ "Docs": "Docs", "Documentation Link": "Documentation Link", "Documentation or external knowledge base.": "Documentation or external knowledge base.", + "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.", "does not exist or might have been removed.": "does not exist or might have been removed.", "Domain": "Domain", "Domain Filter Mode": "Domain Filter Mode", @@ -3358,6 +3364,8 @@ "Performed {{action}} on user {{username}} (ID: {{id}})": "Performed {{action}} on user {{username}} (ID: {{id}})", "Period": "Period", "Periodically check for upstream model changes": "Periodically check for upstream model changes", + "Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.": "Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.", + "Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.": "Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.", "Periodically send ping frames to keep streaming connections active.": "Periodically send ping frames to keep streaming connections active.", "Permanently delete your account and all data": "Permanently delete your account and all data", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Permit Passkey registration on non-HTTPS origins (only recommended for development)", @@ -3673,6 +3681,7 @@ "Recommended to keep this high to avoid upstream throttling.": "Recommended to keep this high to avoid upstream throttling.", "Record IP Address": "Record IP Address", "Record quota usage": "Record quota usage", + "Recover auto-disabled channels only": "Recover auto-disabled channels only", "Recursion Strategy": "Recursion Strategy", "Recursive": "Recursive", "Redeem": "Redeem", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 638cf9abadd1..9954c18a708b 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -151,6 +151,8 @@ "Active models": "Modèles actifs", "Active Tasks": "Tâches actives", "active users": "utilisateurs actifs", + "Actively check all channels": "Vérifier activement tous les canaux", + "Actively check auto-disable-enabled channels": "Vérifier activement les canaux avec désactivation automatique", "Actual Amount": "Montant réel", "Actual Model": "Modèle réel", "Actual Model:": "Modèle réel :", @@ -512,6 +514,8 @@ "Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont", "Auto-disable rules": "Règles de désactivation automatique", "Auto-disable status codes": "Codes de statut de désactivation auto", + "Auto-disable-enabled channels only": "Canaux avec désactivation automatique uniquement", + "Auto-disable-enabled mode probes non-manually-disabled channels with auto-disable enabled.": "Ce mode sonde uniquement les canaux dont la désactivation automatique est activée et qui ne sont pas désactivés manuellement.", "Auto-discover": "Découverte automatique", "Auto-discovers endpoints from the provider": "Découvre automatiquement les points de terminaison du fournisseur", "Auto-fill when one field exists and another is missing": "Remplissage automatique si un champ existe et l'autre est manquant", @@ -783,6 +787,7 @@ "Chat session management": "Gestion des sessions de chat", "ChatCompletions -> Responses Compatibility": "Compatibilité ChatCompletions -> Réponses", "ChatGPT Subscription (Codex)": "ChatGPT Subscription (Codex)", + "Check channels awaiting recovery only": "Vérifier uniquement les canaux en attente de rétablissement", "Check for updates": "Vérifier les mises à jour", "Check in daily to receive random quota rewards": "Connectez-vous quotidiennement pour recevoir des récompenses de quota aléatoires", "Check in now": "Se connecter maintenant", @@ -1437,6 +1442,7 @@ "Docs": "Documents", "Documentation Link": "Lien de la documentation", "Documentation or external knowledge base.": "Documentation ou base de connaissances externe.", + "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "Ne vérifie pas les canaux opérationnels. Revérifie uniquement les canaux désactivés automatiquement et les réactive après leur rétablissement.", "does not exist or might have been removed.": "n'existe pas ou a peut-être été supprimé.", "Domain": "Domaine", "Domain Filter Mode": "Mode de filtre de domaine", @@ -3358,6 +3364,8 @@ "Performed {{action}} on user {{username}} (ID: {{id}})": "Action {{action}} effectuée sur l'utilisateur {{username}} (ID : {{id}})", "Period": "Période", "Periodically check for upstream model changes": "Vérifier périodiquement les changements de modèles en amont", + "Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.": "Vérifie périodiquement tous les canaux sauf ceux désactivés manuellement afin de détecter les pannes et de rétablir automatiquement les canaux.", + "Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.": "Vérifie périodiquement uniquement les canaux dont la désactivation automatique est activée, en excluant les canaux désactivés manuellement.", "Periodically send ping frames to keep streaming connections active.": "Envoyer périodiquement des trames ping pour maintenir les connexions de streaming actives.", "Permanently delete your account and all data": "Supprimer définitivement votre compte et toutes les données", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Autoriser l'enregistrement de Passkey sur des origines non-HTTPS (recommandé uniquement pour le développement)", @@ -3673,6 +3681,7 @@ "Recommended to keep this high to avoid upstream throttling.": "Il est recommandé de maintenir cette valeur élevée pour éviter la limitation en amont.", "Record IP Address": "Enregistrer l'adresse IP", "Record quota usage": "Enregistrer l'utilisation du quota", + "Recover auto-disabled channels only": "Restaurer uniquement les canaux désactivés automatiquement", "Recursion Strategy": "Stratégie de récursion", "Recursive": "Récursif", "Redeem": "Utiliser", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 3b9d4ce693cc..3394a33d3c4d 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -151,6 +151,8 @@ "Active models": "アクティブなモデル", "Active Tasks": "進行中のタスク", "active users": "アクティブユーザー", + "Actively check all channels": "すべてのチャネルを定期チェック", + "Actively check auto-disable-enabled channels": "自動無効化が有効なチャネルを定期チェック", "Actual Amount": "実際の金額", "Actual Model": "実際のモデル", "Actual Model:": "実際のモデル:", @@ -512,6 +514,8 @@ "Auto Sync Upstream Models": "アップストリームモデルの自動同期", "Auto-disable rules": "自動無効化ルール", "Auto-disable status codes": "自動無効化するステータスコード", + "Auto-disable-enabled channels only": "自動無効化が有効なチャネルのみ", + "Auto-disable-enabled mode probes non-manually-disabled channels with auto-disable enabled.": "このモードでは、自動無効化が有効で、手動で無効化されていないチャネルのみを検査します。", "Auto-discover": "自動検出", "Auto-discovers endpoints from the provider": "プロバイダーからエンドポイントを自動検出します", "Auto-fill when one field exists and another is missing": "一方のフィールドがあり他方が欠けている場合に自動補完", @@ -783,6 +787,7 @@ "Chat session management": "チャットセッション管理", "ChatCompletions -> Responses Compatibility": "ChatCompletions → レスポンス互換", "ChatGPT Subscription (Codex)": "ChatGPT Subscription (Codex)", + "Check channels awaiting recovery only": "復旧待ちのチャネルのみチェック", "Check for updates": "更新を確認", "Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう", "Check in now": "今すぐチェックイン", @@ -1437,6 +1442,7 @@ "Docs": "ドキュメント", "Documentation Link": "ドキュメントリンク", "Documentation or external knowledge base.": "ドキュメントまたは外部知識ベース。", + "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "正常なチャネルはチェックしません。自動無効化されたチャネルのみを再チェックし、復旧後に再び有効化します。", "does not exist or might have been removed.": "存在しないか、削除された可能性があります。", "Domain": "ドメイン", "Domain Filter Mode": "ドメインフィルターモード", @@ -3358,6 +3364,8 @@ "Performed {{action}} on user {{username}} (ID: {{id}})": "ユーザー {{username}}(ID: {{id}})に対して {{action}} を実行しました", "Period": "期間", "Periodically check for upstream model changes": "アップストリームモデルの変更を定期的にチェック", + "Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.": "手動で無効化されたものを除くすべてのチャネルを定期チェックし、障害の検出と自動復旧を行います。", + "Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.": "自動無効化が有効なチャネルのみを定期チェックします。手動で無効化されたチャネルは対象外です。", "Periodically send ping frames to keep streaming connections active.": "ストリーミング接続をアクティブに保つために、定期的にpingフレームを送信します。", "Permanently delete your account and all data": "アカウントとすべてのデータを永久に削除", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "非HTTPSオリジンでのパスキー登録を許可する(開発でのみ推奨)", @@ -3673,6 +3681,7 @@ "Recommended to keep this high to avoid upstream throttling.": "アップストリームのスロットリングを避けるため、これを高く保つことを推奨します。", "Record IP Address": "IPアドレスを記録", "Record quota usage": "クォータ使用量を記録", + "Recover auto-disabled channels only": "自動無効化されたチャネルの復旧のみ", "Recursion Strategy": "再帰戦略", "Recursive": "再帰", "Redeem": "引き換え", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 853dc874d463..41f5285f8d1f 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -151,6 +151,8 @@ "Active models": "Активные модели", "Active Tasks": "Активные задачи", "active users": "активных пользователей", + "Actively check all channels": "Активно проверять все каналы", + "Actively check auto-disable-enabled channels": "Активно проверять каналы с автоотключением", "Actual Amount": "Фактическая сумма", "Actual Model": "Фактическая модель", "Actual Model:": "Фактическая модель:", @@ -512,6 +514,8 @@ "Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера", "Auto-disable rules": "Правила автоотключения", "Auto-disable status codes": "Коды автоотключения", + "Auto-disable-enabled channels only": "Только каналы с автовыключением", + "Auto-disable-enabled mode probes non-manually-disabled channels with auto-disable enabled.": "В этом режиме проверяются только каналы с включённым автоматическим отключением, которые не были отключены вручную.", "Auto-discover": "Автообнаружение", "Auto-discovers endpoints from the provider": "Автоматически обнаруживает конечные точки от провайдера", "Auto-fill when one field exists and another is missing": "Автозаполнение, когда одно поле есть, а другое отсутствует", @@ -783,6 +787,7 @@ "Chat session management": "Управление сессиями чата", "ChatCompletions -> Responses Compatibility": "Совместимость ChatCompletions → Ответы", "ChatGPT Subscription (Codex)": "ChatGPT Subscription (Codex)", + "Check channels awaiting recovery only": "Проверять только каналы, ожидающие восстановления", "Check for updates": "Проверить обновления", "Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте", "Check in now": "Войдите сейчас", @@ -1437,6 +1442,7 @@ "Docs": "Документы", "Documentation Link": "Ссылка на документацию", "Documentation or external knowledge base.": "Документация или внешняя база знаний.", + "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "Рабочие каналы не проверяются. Повторно проверяются только автоматически отключённые каналы, которые включаются после восстановления.", "does not exist or might have been removed.": "не существует или, возможно, был удален.", "Domain": "Домен", "Domain Filter Mode": "Режим фильтра домена", @@ -3358,6 +3364,8 @@ "Performed {{action}} on user {{username}} (ID: {{id}})": "Выполнено действие {{action}} над пользователем {{username}} (ID: {{id}})", "Period": "Период", "Periodically check for upstream model changes": "Периодически проверять изменения моделей провайдера", + "Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.": "Периодически проверяет все каналы, кроме отключённых вручную, чтобы выявлять сбои и автоматически восстанавливать каналы.", + "Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.": "Периодически проверяет только каналы с включённым автоотключением, исключая отключённые вручную.", "Periodically send ping frames to keep streaming connections active.": "Периодически отправлять пинг-кадры для поддержания активности потоковых соединений.", "Permanently delete your account and all data": "Безвозвратно удалить ваш аккаунт и все данные", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Разрешить регистрацию Passkey на не-HTTPS источниках (рекомендуется только для разработки)", @@ -3673,6 +3681,7 @@ "Recommended to keep this high to avoid upstream throttling.": "Рекомендуется поддерживать это значение высоким, чтобы избежать регулирования со стороны вышестоящего поставщика.", "Record IP Address": "Записывать IP-адрес", "Record quota usage": "Записывать использование квоты", + "Recover auto-disabled channels only": "Только восстанавливать автоотключённые каналы", "Recursion Strategy": "Стратегия рекурсии", "Recursive": "Рекурсивно", "Redeem": "Обменять квоту", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index cdc1a12626e1..16f5b195e48c 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -151,6 +151,8 @@ "Active models": "Mô hình đang hoạt động", "Active Tasks": "Tác vụ đang hoạt động", "active users": "Người dùng tích cực", + "Actively check all channels": "Chủ động kiểm tra tất cả kênh", + "Actively check auto-disable-enabled channels": "Chủ động kiểm tra kênh đã bật tự động vô hiệu hóa", "Actual Amount": "Số tiền thực tế", "Actual Model": "Mô hình thực tế", "Actual Model:": "Mô hình thực tế:", @@ -512,6 +514,8 @@ "Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn", "Auto-disable rules": "Quy tắc tự động tắt", "Auto-disable status codes": "Mã trạng thái tự tắt", + "Auto-disable-enabled channels only": "Chỉ kênh đã bật tự động vô hiệu hóa", + "Auto-disable-enabled mode probes non-manually-disabled channels with auto-disable enabled.": "Chế độ này chỉ kiểm tra các kênh đã bật tự động vô hiệu hóa và không bị vô hiệu hóa thủ công.", "Auto-discover": "Tự động khám phá", "Auto-discovers endpoints from the provider": "Tự động khám phá các điểm cuối từ nhà cung cấp", "Auto-fill when one field exists and another is missing": "Tự động điền khi một trường có giá trị và trường khác thiếu", @@ -783,6 +787,7 @@ "Chat session management": "Quản lý phiên trò chuyện", "ChatCompletions -> Responses Compatibility": "Tương thích ChatCompletions -> Phản hồi", "ChatGPT Subscription (Codex)": "ChatGPT Subscription (Codex)", + "Check channels awaiting recovery only": "Chỉ kiểm tra các kênh đang chờ khôi phục", "Check for updates": "Kiểm tra cập nhật", "Check in daily to receive random quota rewards": "Nhận phòng hàng ngày để nhận phần thưởng theo hạn ngạch ngẫu nhiên", "Check in now": "Điểm danh ngay", @@ -1437,6 +1442,7 @@ "Docs": "Tài liệu", "Documentation Link": "Liên kết tài liệu", "Documentation or external knowledge base.": "Tài liệu hoặc cơ sở kiến thức bên ngoài.", + "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "Không kiểm tra kênh đang hoạt động bình thường. Chỉ kiểm tra lại kênh bị hệ thống tự động vô hiệu hóa và bật lại sau khi khôi phục.", "does not exist or might have been removed.": "không tồn tại hoặc có thể đã bị xóa.", "Domain": "Miền", "Domain Filter Mode": "Chế độ lọc miền", @@ -3358,6 +3364,8 @@ "Performed {{action}} on user {{username}} (ID: {{id}})": "Đã thực hiện {{action}} trên người dùng {{username}} (ID: {{id}})", "Period": "Khoảng thời gian", "Periodically check for upstream model changes": "Kiểm tra định kỳ các thay đổi mô hình nguồn", + "Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.": "Định kỳ kiểm tra tất cả kênh trừ kênh bị vô hiệu hóa thủ công để phát hiện lỗi và tự động khôi phục.", + "Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.": "Chỉ định kỳ kiểm tra kênh đã bật tự động vô hiệu hóa, không kiểm tra kênh bị vô hiệu hóa thủ công.", "Periodically send ping frames to keep streaming connections active.": "Định kỳ gửi các khung ping để duy trì các kết nối truyền phát hoạt động.", "Permanently delete your account and all data": "Xóa vĩnh viễn tài khoản của bạn và tất cả dữ liệu", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Cho phép đăng ký Passkey trên các nguồn gốc không phải HTTPS (chỉ khuyến nghị cho mục đích phát triển)", @@ -3673,6 +3681,7 @@ "Recommended to keep this high to avoid upstream throttling.": "Khuyến nghị giữ mức này cao để tránh điều tiết từ phía thượng nguồn.", "Record IP Address": "Ghi lại địa chỉ IP", "Record quota usage": "Ghi lại mức sử dụng hạn mức", + "Recover auto-disabled channels only": "Chỉ khôi phục kênh bị tự động vô hiệu hóa", "Recursion Strategy": "Chiến lược đệ quy", "Recursive": "Đệ quy", "Redeem": "Đổi", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index f8b5fafd8d2c..16ff3cd7b987 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -151,6 +151,8 @@ "Active models": "活躍模型", "Active Tasks": "進行中任務", "active users": "活躍用戶", + "Actively check all channels": "主動檢查全部渠道", + "Actively check auto-disable-enabled channels": "主動檢查已啟用自動停用的渠道", "Actual Amount": "實付金額", "Actual Model": "實際模型", "Actual Model:": "實際模型:", @@ -512,6 +514,8 @@ "Auto Sync Upstream Models": "自動同步上游模型", "Auto-disable rules": "自動停用規則", "Auto-disable status codes": "自動停用狀態碼", + "Auto-disable-enabled channels only": "僅測試已啟用自動停用的渠道", + "Auto-disable-enabled mode probes non-manually-disabled channels with auto-disable enabled.": "此模式僅探測已啟用自動停用且未被手動停用的渠道。", "Auto-discover": "自動發現", "Auto-discovers endpoints from the provider": "自動從供應商發現端點", "Auto-fill when one field exists and another is missing": "在一個欄位有值、另一個缺失時自動補齊", @@ -783,6 +787,7 @@ "Chat session management": "聊天對話管理", "ChatCompletions -> Responses Compatibility": "ChatCompletions → 回應兼容", "ChatGPT Subscription (Codex)": "ChatGPT Subscription (Codex)", + "Check channels awaiting recovery only": "僅檢查被自動停用的渠道", "Check for updates": "檢查更新", "Check in daily to receive random quota rewards": "每日簽到可獲得隨機額度獎勵", "Check in now": "立即簽到", @@ -1437,6 +1442,7 @@ "Docs": "文件", "Documentation Link": "文件連結", "Documentation or external knowledge base.": "文件或外部知識庫。", + "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "不檢查正常渠道,只複查被系統自動停用的渠道,並在恢復後重新啟用。", "does not exist or might have been removed.": "不存在或可能已被移除。", "Domain": "域名", "Domain Filter Mode": "域名過濾模式", @@ -3358,6 +3364,8 @@ "Performed {{action}} on user {{username}} (ID: {{id}})": "對用戶 {{username}}(ID: {{id}})執行 {{action}}", "Period": "時間範圍", "Periodically check for upstream model changes": "定期檢查上游模型是否有變更", + "Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.": "定時檢查除手動停用外的全部渠道,用於主動發現故障並自動恢復渠道。", + "Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.": "只定時檢查已啟用「自動停用」的渠道;未啟用該選項和手動停用的渠道不會被檢查。", "Periodically send ping frames to keep streaming connections active.": "定期發送 ping 幀以保持串流連接處於活動狀態。", "Permanently delete your account and all data": "永久刪除您的用戶和所有數據", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "允許在非 HTTPS 源上註冊通行金鑰(僅建議用於開發)", @@ -3673,6 +3681,7 @@ "Recommended to keep this high to avoid upstream throttling.": "建議保持此值較高,以避免上游限流。", "Record IP Address": "記錄 IP 地址", "Record quota usage": "記錄配額使用量", + "Recover auto-disabled channels only": "僅恢復自動停用的渠道", "Recursion Strategy": "遞迴策略", "Recursive": "遞迴", "Redeem": "兌換額度", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 842991e714f0..9cabd02dde03 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -151,6 +151,8 @@ "Active models": "活跃模型", "Active Tasks": "进行中任务", "active users": "活跃用户", + "Actively check all channels": "主动检查全部渠道", + "Actively check auto-disable-enabled channels": "主动检查已开启自动禁用的渠道", "Actual Amount": "实付金额", "Actual Model": "实际模型", "Actual Model:": "实际模型:", @@ -512,6 +514,8 @@ "Auto Sync Upstream Models": "自动同步上游模型", "Auto-disable rules": "自动禁用规则", "Auto-disable status codes": "自动禁用状态码", + "Auto-disable-enabled channels only": "仅测试已开启自动禁用的渠道", + "Auto-disable-enabled mode probes non-manually-disabled channels with auto-disable enabled.": "此模式仅探测已开启自动禁用且未被手动禁用的渠道。", "Auto-discover": "自动发现", "Auto-discovers endpoints from the provider": "自动从提供商发现端点", "Auto-fill when one field exists and another is missing": "在一个字段有值、另一个缺失时自动补齐", @@ -783,6 +787,7 @@ "Chat session management": "聊天会话管理", "ChatCompletions -> Responses Compatibility": "ChatCompletions → 响应兼容", "ChatGPT Subscription (Codex)": "ChatGPT Subscription (Codex)", + "Check channels awaiting recovery only": "仅检查被自动禁用的渠道", "Check for updates": "检查更新", "Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励", "Check in now": "立即签到", @@ -1437,6 +1442,7 @@ "Docs": "文档", "Documentation Link": "文档链接", "Documentation or external knowledge base.": "文档或外部知识库。", + "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "不检查正常渠道,只复查被系统自动禁用的渠道,并在恢复后重新启用。", "does not exist or might have been removed.": "不存在或可能已被移除。", "Domain": "域名", "Domain Filter Mode": "域名过滤模式", @@ -3358,6 +3364,8 @@ "Performed {{action}} on user {{username}} (ID: {{id}})": "对用户 {{username}}(ID: {{id}})执行 {{action}}", "Period": "时间范围", "Periodically check for upstream model changes": "定期检查上游模型是否有变更", + "Periodically checks all channels except manually disabled ones to detect failures and recover channels automatically.": "定时检查除手动禁用外的全部渠道,用于主动发现故障并自动恢复渠道。", + "Periodically checks only channels with auto-disable enabled, excluding manually disabled channels.": "只定时检查已开启“自动禁用”的渠道;未开启该选项和手动禁用的渠道不会被检查。", "Periodically send ping frames to keep streaming connections active.": "定期发送 ping 帧以保持流连接处于活动状态。", "Permanently delete your account and all data": "永久删除您的帐户和所有数据", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "允许在非 HTTPS 源上注册通行密钥(仅建议用于开发)", @@ -3673,6 +3681,7 @@ "Recommended to keep this high to avoid upstream throttling.": "建议保持此值较高,以避免上游限流。", "Record IP Address": "记录 IP 地址", "Record quota usage": "记录配额使用量", + "Recover auto-disabled channels only": "仅恢复自动禁用的渠道", "Recursion Strategy": "递归策略", "Recursive": "递归", "Redeem": "兑换额度", From 7dd1000a190d1c810fa0d5723770341106742a1b Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:47:55 +0800 Subject: [PATCH 12/18] perf(web): debounce server and large-list searches (#6727) --- .../keys/components/api-keys-table.tsx | 1 + .../models/components/deployments-table.tsx | 1 + .../dialogs/upstream-conflict-dialog.tsx | 8 +++--- .../models/components/models-table.tsx | 1 + web/src/features/pricing/hooks/use-filters.ts | 7 +++-- .../components/redemptions-table.tsx | 1 + .../models/channel-selector-dialog.tsx | 8 +++--- .../models/model-ratio-visual-editor.tsx | 1 + .../models/upstream-ratio-sync-table.tsx | 8 +++--- .../wallet/hooks/use-billing-history.ts | 27 ++++++++++++++----- 10 files changed, 45 insertions(+), 18 deletions(-) diff --git a/web/src/features/keys/components/api-keys-table.tsx b/web/src/features/keys/components/api-keys-table.tsx index 3561df5254c9..2ee497924944 100644 --- a/web/src/features/keys/components/api-keys-table.tsx +++ b/web/src/features/keys/components/api-keys-table.tsx @@ -307,6 +307,7 @@ export function ApiKeysTable() { applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by name...'), + searchDebounceMs: 500, additionalSearch: ( . For commercial licensing, please contact support@quantumnous.com */ import { useQueryClient } from '@tanstack/react-query' -import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table' +import type { ColumnDef, RowSelectionState } from '@tanstack/react-table' import { Search, Info, @@ -48,6 +48,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { useDebounce } from '@/hooks/use-debounce' import { useIsMobile } from '@/hooks/use-mobile' import { applyUpstreamOverwrite } from '../../api' @@ -117,6 +118,7 @@ export function UpstreamConflictDialog({ } = useModels() const isMobile = useIsMobile() const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 200) const [isSubmitting, setIsSubmitting] = useState(false) const [rowSelection, setRowSelection] = useState({}) const [pageSize, setPageSize] = useState(10) @@ -149,7 +151,7 @@ export function UpstreamConflictDialog({ const totalModels = upstreamConflicts.length const totalFields = conflictRows.length - const normalizedSearch = search.trim().toLowerCase() + const normalizedSearch = debouncedSearch.trim().toLowerCase() const { matchingModelNames, visibleRowIds } = useMemo(() => { if (!normalizedSearch) { @@ -396,7 +398,7 @@ export function UpstreamConflictDialog({ const payload: SyncOverwritePayload[] = Object.entries(groupedSelections) .map(([modelName, fields]) => ({ model_name: modelName, - fields: Array.from(fields), + fields: [...fields], })) .filter((item) => item.fields.length > 0) diff --git a/web/src/features/models/components/models-table.tsx b/web/src/features/models/components/models-table.tsx index 91640ddbf522..4a9b7b6ecd07 100644 --- a/web/src/features/models/components/models-table.tsx +++ b/web/src/features/models/components/models-table.tsx @@ -202,6 +202,7 @@ export function ModelsTable() { applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by model name...'), + searchDebounceMs: 500, filters: [ { columnId: 'status', diff --git a/web/src/features/pricing/hooks/use-filters.ts b/web/src/features/pricing/hooks/use-filters.ts index 18415b8a434d..c055153f612a 100644 --- a/web/src/features/pricing/hooks/use-filters.ts +++ b/web/src/features/pricing/hooks/use-filters.ts @@ -19,6 +19,8 @@ For commercial licensing, please contact support@quantumnous.com import { useSearch } from '@tanstack/react-router' import { useMemo, useCallback, useState } from 'react' +import { useDebounce } from '@/hooks/use-debounce' + import { FILTER_ALL, SORT_OPTIONS, @@ -67,6 +69,7 @@ export function useFilters(models: PricingModel[]) { })) const searchInput = filterState.search || '' + const debouncedSearchInput = useDebounce(searchInput, 200) const sortBy = filterState.sort || SORT_OPTIONS.NAME const vendorFilter = filterState.vendor || FILTER_ALL const groupFilter = filterState.group || FILTER_ALL @@ -147,7 +150,7 @@ export function useFilters(models: PricingModel[]) { if (!models || models.length === 0) return [] return filterAndSortModels(models, { - search: searchInput, + search: debouncedSearchInput, vendor: vendorFilter, group: groupFilter, quotaType: quotaTypeFilter, @@ -157,7 +160,7 @@ export function useFilters(models: PricingModel[]) { }) }, [ models, - searchInput, + debouncedSearchInput, vendorFilter, groupFilter, quotaTypeFilter, diff --git a/web/src/features/redemption-codes/components/redemptions-table.tsx b/web/src/features/redemption-codes/components/redemptions-table.tsx index 391d64cff42a..850433eefffa 100644 --- a/web/src/features/redemption-codes/components/redemptions-table.tsx +++ b/web/src/features/redemption-codes/components/redemptions-table.tsx @@ -171,6 +171,7 @@ export function RedemptionsTable() { applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by name or ID...'), + searchDebounceMs: 500, filters: [ { columnId: 'status', diff --git a/web/src/features/system-settings/models/channel-selector-dialog.tsx b/web/src/features/system-settings/models/channel-selector-dialog.tsx index e8b4d2331ae7..0be3348ab0f7 100644 --- a/web/src/features/system-settings/models/channel-selector-dialog.tsx +++ b/web/src/features/system-settings/models/channel-selector-dialog.tsx @@ -39,6 +39,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { useDebounce } from '@/hooks/use-debounce' import type { UpstreamChannel } from '../types' import { @@ -80,6 +81,7 @@ export function ChannelSelectorDialog({ }: ChannelSelectorDialogProps) { const { t } = useTranslation() const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 200) const [rowSelection, setRowSelection] = useState({}) useEffect(() => { @@ -273,15 +275,15 @@ export function ChannelSelectorDialog({ ) const filteredChannels = useMemo(() => { - if (!search.trim()) return channels + if (!debouncedSearch.trim()) return channels - const searchLower = search.toLowerCase() + const searchLower = debouncedSearch.toLowerCase() return channels.filter( (ch) => ch.name.toLowerCase().includes(searchLower) || ch.base_url.toLowerCase().includes(searchLower) ) - }, [channels, search]) + }, [channels, debouncedSearch]) const sortedChannels = useMemo(() => { return [...filteredChannels].sort((a, b) => { diff --git a/web/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/src/features/system-settings/models/model-ratio-visual-editor.tsx index f50093cf0e92..5013455c9bba 100644 --- a/web/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -682,6 +682,7 @@ const ModelRatioVisualEditorComponent = forwardRef< ('') const dataSource = useMemo(() => { @@ -106,8 +108,8 @@ export function UpstreamRatioSyncTable({ const filteredData = useMemo(() => { let data = dataSource - if (search.trim()) { - const lower = search.toLowerCase() + if (debouncedSearch.trim()) { + const lower = debouncedSearch.toLowerCase() data = data.filter((row) => row.model.toLowerCase().includes(lower)) } @@ -116,7 +118,7 @@ export function UpstreamRatioSyncTable({ } return data - }, [dataSource, search, ratioTypeFilter]) + }, [dataSource, debouncedSearch, ratioTypeFilter]) const upstreamNames = useMemo(() => { const set = new Set() diff --git a/web/src/features/wallet/hooks/use-billing-history.ts b/web/src/features/wallet/hooks/use-billing-history.ts index 48aead6a0bd1..54f98d5f1081 100644 --- a/web/src/features/wallet/hooks/use-billing-history.ts +++ b/web/src/features/wallet/hooks/use-billing-history.ts @@ -17,10 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import i18next from 'i18next' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { toast } from 'sonner' import { useIsAdmin } from '@/hooks/use-admin' +import { useDebounce } from '@/hooks/use-debounce' import { getUserBillingHistory, @@ -50,6 +51,8 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) { const [page, setPage] = useState(initialPage) const [pageSize, setPageSize] = useState(initialPageSize) const [keyword, setKeyword] = useState('') + const debouncedKeyword = useDebounce(keyword) + const requestIdRef = useRef(0) const [loading, setLoading] = useState(false) const [completing, setCompleting] = useState(false) @@ -57,11 +60,14 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) { * Fetch billing history */ const fetchBillingHistory = useCallback(async () => { + const requestId = ++requestIdRef.current setLoading(true) try { const response = isAdmin - ? await getAllBillingHistory(page, pageSize, keyword) - : await getUserBillingHistory(page, pageSize, keyword) + ? await getAllBillingHistory(page, pageSize, debouncedKeyword) + : await getUserBillingHistory(page, pageSize, debouncedKeyword) + + if (requestId !== requestIdRef.current) return if (isApiSuccess(response) && response.data) { setRecords(response.data.items || []) @@ -74,15 +80,19 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) { setTotal(0) } } catch (error) { + if (requestId !== requestIdRef.current) return + // eslint-disable-next-line no-console console.error('Failed to fetch billing history:', error) toast.error(i18next.t('Failed to load billing history')) setRecords([]) setTotal(0) } finally { - setLoading(false) + if (requestId === requestIdRef.current) { + setLoading(false) + } } - }, [isAdmin, page, pageSize, keyword]) + }, [debouncedKeyword, isAdmin, page, pageSize]) /** * Complete a pending order (admin only) @@ -137,14 +147,17 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) { * Search by keyword */ const handleSearch = useCallback((newKeyword: string) => { + requestIdRef.current += 1 setKeyword(newKeyword) setPage(1) // Reset to first page when searching }, []) - // Fetch data when dependencies change + // Fetch data after the search draft has settled. useEffect(() => { + if (keyword !== debouncedKeyword) return + fetchBillingHistory() - }, [fetchBillingHistory]) + }, [debouncedKeyword, fetchBillingHistory, keyword]) return { records, From eab18a83579187f880139894dd9e7f06d1a492ce Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:48:14 +0800 Subject: [PATCH 13/18] fix: record reasoning effort consistently in usage logs (#6641) --- relay/channel/deepseek/adaptor.go | 6 +- relay/channel/openai/adaptor.go | 4 +- relay/channel/xai/adaptor.go | 2 +- relay/claude_handler.go | 5 + relay/common/override.go | 51 ++++++++ relay/common/override_test.go | 111 ++++++++++++++++++ relay/common/relay_info.go | 41 ++++++- relay/common/relay_info_test.go | 98 ++++++++++++++++ .../components/dialogs/details-dialog.tsx | 10 +- web/src/features/usage-logs/lib/format.ts | 19 +++ 10 files changed, 333 insertions(+), 14 deletions(-) diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index ff13efa44bf4..72805c8c8654 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -115,7 +115,7 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d if info.ChannelMeta != nil { info.UpstreamModelName = baseModel } - info.ReasoningEffort = effort + info.SetReasoningEffort(effort) } return nil } @@ -146,7 +146,7 @@ func applyDeepSeekV4ClaudeThinkingSuffix(info *relaycommon.RelayInfo, request *d if info.ChannelMeta != nil { info.UpstreamModelName = baseModel } - info.ReasoningEffort = effort + info.SetReasoningEffort(effort) } return nil } @@ -185,7 +185,7 @@ func applyDeepSeekV4ResponsesThinkingSuffix(info *relaycommon.RelayInfo, request } } if info != nil && request.Reasoning != nil { - info.ReasoningEffort = request.Reasoning.Effort + info.SetReasoningEffort(request.Reasoning.Effort) } } diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 4f1c42863dba..64ae3102b3c2 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -352,7 +352,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn request.Model = originModel } - info.ReasoningEffort = request.ReasoningEffort + info.SetReasoningEffort(request.ReasoningEffort) // o系列模型developer适配(o1-mini除外) if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") { @@ -615,7 +615,7 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo request.Model = originModel } if info != nil && request.Reasoning != nil && request.Reasoning.Effort != "" { - info.ReasoningEffort = request.Reasoning.Effort + info.SetReasoningEffort(request.Reasoning.Effort) } return request, nil } diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 0db042f06ea5..62b41b33987c 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -85,7 +85,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn request.ReasoningEffort = "low" request.Model = strings.TrimSuffix(request.Model, "-low") } - info.ReasoningEffort = request.ReasoningEffort + info.SetReasoningEffort(request.ReasoningEffort) info.UpstreamModelName = request.Model } return request, nil diff --git a/relay/claude_handler.go b/relay/claude_handler.go index c8b01f7ac8dc..ff7854469d7f 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -106,6 +106,11 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } info.UpstreamModelName = request.Model } + if !model_setting.GetGlobalSettings().PassThroughRequestEnabled && !info.ChannelSetting.PassThroughBodyEnabled { + if effort := request.GetEfforts(); effort != "" { + info.SetReasoningEffort(effort) + } + } if info.ChannelSetting.SystemPrompt != "" { if request.System == nil { diff --git a/relay/common/override.go b/relay/common/override.go index f92daf77552f..558fd55aae9b 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -30,6 +30,11 @@ var paramOverrideSensitivePathPrefixes = []string{ "model", "original_model", "upstream_model", + "reasoning", + "reasoning_effort", + "output_config", + "generationConfig.thinkingConfig", + "generation_config.thinking_config", "service_tier", "inference_geo", "speed", @@ -191,6 +196,7 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte, if err != nil { return nil, err } + syncReasoningEffortAfterParamOverride(info, jsonData, result) syncRuntimeHeaderOverrideFromContext(info, overrideCtx) if info != nil { if recorder != nil { @@ -202,6 +208,51 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte, return result, nil } +func syncReasoningEffortAfterParamOverride(info *RelayInfo, before, after []byte) { + if info == nil { + return + } + _, existedBefore := extractReasoningEffortFromJSON(info.GetFinalRequestRelayFormat(), before) + effort, existsAfter := extractReasoningEffortFromJSON(info.GetFinalRequestRelayFormat(), after) + if existsAfter { + info.SetReasoningEffort(effort) + return + } + if existedBefore { + info.SetReasoningEffort("") + } +} + +func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (string, bool) { + var paths []string + switch format { + case types.RelayFormatOpenAI: + paths = []string{"reasoning_effort", "reasoning.effort"} + case types.RelayFormatOpenAIResponses: + paths = []string{"reasoning.effort"} + case types.RelayFormatClaude: + paths = []string{"output_config.effort"} + case types.RelayFormatGemini: + paths = []string{ + "generationConfig.thinkingConfig.thinkingLevel", + "generation_config.thinking_config.thinking_level", + } + default: + return "", false + } + for _, path := range paths { + value := gjson.GetBytes(data, path) + if !value.Exists() { + continue + } + if value.Type != gjson.String { + return "", true + } + return strings.TrimSpace(value.String()), true + } + return "", false +} + func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool { if common.DebugEnabled { return true diff --git a/relay/common/override_test.go b/relay/common/override_test.go index c5fc7f5d5f04..e1f84eaf4bbc 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/setting/model_setting" "github.com/samber/lo" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -2302,3 +2303,113 @@ func assertJSONEqual(t *testing.T, want, got string) { t.Fatalf("json not equal\nwant: %s\ngot: %s", want, got) } } + +func TestApplyParamOverrideWithRelayInfoSynchronizesReasoningEffort(t *testing.T) { + originalDebugEnabled := common2.DebugEnabled + common2.DebugEnabled = false + t.Cleanup(func() { + common2.DebugEnabled = originalDebugEnabled + }) + + tests := []struct { + name string + relayFormat types.RelayFormat + initialEffort string + input string + operation map[string]interface{} + expected string + }{ + { + name: "Responses set", + relayFormat: types.RelayFormatOpenAIResponses, + initialEffort: "high", + input: `{"reasoning":{"effort":"high"}}`, + operation: map[string]interface{}{"mode": "set", "path": "reasoning.effort", "value": "max"}, + expected: "max", + }, + { + name: "chat delete", + relayFormat: types.RelayFormatOpenAI, + initialEffort: "high", + input: `{"reasoning_effort":"high"}`, + operation: map[string]interface{}{"mode": "delete", "path": "reasoning_effort"}, + expected: "", + }, + { + name: "OpenRouter nested set", + relayFormat: types.RelayFormatOpenAI, + initialEffort: "medium", + input: `{"reasoning":{"effort":"medium"}}`, + operation: map[string]interface{}{"mode": "set", "path": "reasoning.effort", "value": "xhigh"}, + expected: "xhigh", + }, + { + name: "Claude output config set", + relayFormat: types.RelayFormatClaude, + initialEffort: "high", + input: `{"output_config":{"effort":"high"}}`, + operation: map[string]interface{}{"mode": "set", "path": "output_config.effort", "value": "max"}, + expected: "max", + }, + { + name: "Gemini thinking level set", + relayFormat: types.RelayFormatGemini, + initialEffort: "medium", + input: `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"}}}`, + operation: map[string]interface{}{"mode": "set", "path": "generationConfig.thinkingConfig.thinkingLevel", "value": "high"}, + expected: "high", + }, + { + name: "non-string value clears effort", + relayFormat: types.RelayFormatOpenAIResponses, + initialEffort: "high", + input: `{"reasoning":{"effort":"high"}}`, + operation: map[string]interface{}{"mode": "set", "path": "reasoning.effort", "value": 42}, + expected: "", + }, + { + name: "unrelated override preserves converter-derived effort", + relayFormat: types.RelayFormatClaude, + initialEffort: "high", + input: `{"thinking":{"type":"adaptive"},"max_tokens":4096}`, + operation: map[string]interface{}{"mode": "set", "path": "max_tokens", "value": 8192}, + expected: "high", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &RelayInfo{ + RelayFormat: tt.relayFormat, + ReasoningEffort: tt.initialEffort, + ChannelMeta: &ChannelMeta{ParamOverride: map[string]interface{}{ + "operations": []interface{}{tt.operation}, + }}, + } + + _, err := ApplyParamOverrideWithRelayInfo([]byte(tt.input), info) + require.NoError(t, err) + assert.Equal(t, tt.expected, info.ReasoningEffort) + }) + } +} + +func TestReasoningEffortOverrideIsAuditedWithoutDebugMode(t *testing.T) { + originalDebugEnabled := common2.DebugEnabled + common2.DebugEnabled = false + t.Cleanup(func() { + common2.DebugEnabled = originalDebugEnabled + }) + info := &RelayInfo{ + RelayFormat: types.RelayFormatOpenAIResponses, + ChannelMeta: &ChannelMeta{ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{"mode": "set", "path": "reasoning.effort", "value": "max"}, + }, + }}, + } + + _, err := ApplyParamOverrideWithRelayInfo([]byte(`{"reasoning":{"effort":"high"}}`), info) + require.NoError(t, err) + assert.Equal(t, []string{"set reasoning.effort = max"}, info.ParamOverrideAudit) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 594e2640182d..b0bb19bdca3b 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -234,6 +234,11 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) { // Channel identity feeds the converter options snapshot (e.g. // OpenRouterDialect); drop the cache so a cross-channel retry rebuilds it. info.convOptions = nil + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelMeta.ChannelSetting.PassThroughBodyEnabled { + info.ReasoningEffort = "" + } else { + info.ReasoningEffort = reasoningEffortFromRequest(info.Request) + } // reset some fields based on channel meta // 重置某些字段,例如模型名称等 @@ -435,6 +440,36 @@ func GenRelayInfoOpenAI(c *gin.Context, request dto.Request) *RelayInfo { return info } +func reasoningEffortFromRequest(request dto.Request) string { + var effort string + switch req := request.(type) { + case *dto.GeneralOpenAIRequest: + if req == nil { + return "" + } + effort = req.ReasoningEffort + if strings.TrimSpace(effort) == "" && len(req.Reasoning) > 0 { + value := gjson.GetBytes(req.Reasoning, "effort") + if value.Type == gjson.String { + effort = value.String() + } + } + case *dto.OpenAIResponsesRequest: + if req != nil && req.Reasoning != nil { + effort = req.Reasoning.Effort + } + case *dto.ClaudeRequest: + if req != nil { + effort = req.GetEfforts() + } + case *dto.GeminiChatRequest: + if req != nil && req.GenerationConfig.ThinkingConfig != nil { + effort = req.GenerationConfig.ThinkingConfig.ThinkingLevel + } + } + return strings.TrimSpace(effort) +} + func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { //channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) @@ -465,8 +500,10 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { if reqId == "" { reqId = common.NewRequestId() } + reasoningEffort := reasoningEffortFromRequest(request) info := &RelayInfo{ - Request: request, + Request: request, + ReasoningEffort: reasoningEffort, RequestId: reqId, UserId: common.GetContextKeyInt(c, constant.ContextKeyUserId), @@ -740,7 +777,7 @@ func (info *RelayInfo) SetReasoningEffort(effort string) { if info == nil { return } - info.ReasoningEffort = effort + info.ReasoningEffort = strings.TrimSpace(effort) } func (info *RelayInfo) EnsureClaudeConvertInfo() *convmeta.ClaudeConvertInfo { diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go index 9e58f3f92d8b..42a0f8567bfe 100644 --- a/relay/common/relay_info_test.go +++ b/relay/common/relay_info_test.go @@ -1,10 +1,14 @@ package common import ( + "encoding/json" + "net/http/httptest" "testing" + "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta" "github.com/QuantumNous/new-api/relaykit/types" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -78,3 +82,97 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) { assert.NotNil(t, firstOptions.Gemini.SafetySetting) assert.NotNil(t, firstOptions.PreserveThinkingSuffix) } + +func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + path string + relayFormat types.RelayFormat + request dto.Request + expected string + }{ + { + name: "OpenAI chat top-level effort", + path: "/v1/chat/completions", + relayFormat: types.RelayFormatOpenAI, + request: &dto.GeneralOpenAIRequest{Model: "gpt-5.6-sol", ReasoningEffort: " high "}, + expected: "high", + }, + { + name: "OpenRouter nested chat effort", + path: "/v1/chat/completions", + relayFormat: types.RelayFormatOpenAI, + request: &dto.GeneralOpenAIRequest{Model: "anthropic/claude", Reasoning: json.RawMessage(`{"effort":"xhigh"}`)}, + expected: "xhigh", + }, + { + name: "OpenAI Responses effort", + path: "/v1/responses", + relayFormat: types.RelayFormatOpenAIResponses, + request: &dto.OpenAIResponsesRequest{Model: "gpt-5.6-sol", Reasoning: &dto.Reasoning{Effort: "max"}}, + expected: "max", + }, + { + name: "explicit none is preserved", + path: "/v1/responses", + relayFormat: types.RelayFormatOpenAIResponses, + request: &dto.OpenAIResponsesRequest{Model: "gpt-5.6-sol", Reasoning: &dto.Reasoning{Effort: "none"}}, + expected: "none", + }, + { + name: "non-string nested effort is ignored", + path: "/v1/chat/completions", + relayFormat: types.RelayFormatOpenAI, + request: &dto.GeneralOpenAIRequest{Model: "anthropic/claude", Reasoning: json.RawMessage(`{"effort":42}`)}, + expected: "", + }, + { + name: "Claude output config effort", + path: "/v1/messages", + relayFormat: types.RelayFormatClaude, + request: &dto.ClaudeRequest{Model: "claude-opus-4-7", OutputConfig: json.RawMessage(`{"effort":"medium"}`)}, + expected: "medium", + }, + { + name: "Gemini thinking level", + path: "/v1beta/models/gemini-3-pro:generateContent", + relayFormat: types.RelayFormatGemini, + request: &dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{ + ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingLevel: "low"}, + }}, + expected: "low", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", tt.path, nil) + + info, err := GenRelayInfo(ctx, tt.relayFormat, tt.request, nil) + require.NoError(t, err) + assert.Equal(t, tt.expected, info.ReasoningEffort) + }) + } +} + +func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + request := &dto.OpenAIResponsesRequest{ + Model: "gpt-5.6-sol", + Reasoning: &dto.Reasoning{Effort: "max"}, + } + info, err := GenRelayInfo(ctx, types.RelayFormatOpenAIResponses, request, nil) + require.NoError(t, err) + + info.SetReasoningEffort("high") + info.InitChannelMeta(ctx) + assert.Equal(t, "max", info.ReasoningEffort) + + info.SetReasoningEffort("low") + info.InitChannelMeta(ctx) + assert.Equal(t, "max", info.ReasoningEffort) +} diff --git a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx index 8f57ac6ba337..0c8478f41380 100644 --- a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -74,6 +74,7 @@ import { isViolationFeeLog, getFirstResponseTimeColor, getResponseTimeColor, + getReasoningEffortVariant, renderAuditContent, } from '../../lib/format' import { @@ -604,12 +605,9 @@ export function DetailsDialog(props: DetailsDialogProps) { const useChannel = other?.admin_info?.use_channel const channelChain = useChannel && useChannel.length > 0 ? useChannel.join(' → ') : undefined - let reasoningEffortVariant: StatusBadgeProps['variant'] = 'green' - if (other?.reasoning_effort === 'high') { - reasoningEffortVariant = 'orange' - } else if (other?.reasoning_effort === 'medium') { - reasoningEffortVariant = 'yellow' - } + const reasoningEffortVariant = getReasoningEffortVariant( + other?.reasoning_effort + ) return ( Date: Mon, 10 Aug 2026 12:49:06 +0800 Subject: [PATCH 14/18] feat(relay): expose user and group context to parameter overrides (#6534) --- relay/common/override.go | 8 +++ relay/common/override_test.go | 110 ++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/relay/common/override.go b/relay/common/override.go index 558fd55aae9b..b1a7d17744fa 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -2094,6 +2094,10 @@ func mergeObjects(data []byte, path string, value interface{}, keepOrigin bool) // 目前内置以下字段: // - upstream_model/model:始终为通道映射后的上游模型名。 // - original_model:请求最初指定的模型名。 +// - user_id:已认证用户 ID。 +// - user_group:用户所属分组。 +// - token_group:令牌指定的分组;未指定时回退为用户分组。 +// - using_group:当前实际使用的分组,自动跨分组重试时可能变化。 // - request_path:请求路径 // - is_channel_test:是否为渠道测试请求(同 is_test)。 func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { @@ -2102,6 +2106,10 @@ func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { } ctx := make(map[string]interface{}) + ctx["user_id"] = info.UserId + ctx["user_group"] = info.UserGroup + ctx["token_group"] = info.TokenGroup + ctx["using_group"] = info.UsingGroup if info.ChannelMeta != nil && info.ChannelMeta.UpstreamModelName != "" { ctx["model"] = info.ChannelMeta.UpstreamModelName ctx["upstream_model"] = info.ChannelMeta.UpstreamModelName diff --git a/relay/common/override_test.go b/relay/common/override_test.go index e1f84eaf4bbc..19af348df99e 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -1260,6 +1260,116 @@ func TestApplyParamOverrideConditionFromRetryAndLastErrorContext(t *testing.T) { assertJSONEqual(t, `{"temperature":0.1}`, string(out)) } +func TestApplyParamOverrideConditionByUserAndGPTModel(t *testing.T) { + paramOverride := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "service_tier", + "mode": "set", + "value": "priority", + "logic": "AND", + "conditions": []interface{}{ + map[string]interface{}{ + "path": "user_id", + "mode": "full", + "value": 1, + }, + map[string]interface{}{ + "path": "upstream_model", + "mode": "contains", + "value": "gpt", + }, + }, + }, + }, + } + + tests := []struct { + name string + userID int + model string + expected string + }{ + { + name: "target user and GPT model", + userID: 1, + model: "gpt-5.2", + expected: `{"model":"gpt-5.2","service_tier":"priority"}`, + }, + { + name: "other user", + userID: 2, + model: "gpt-5.2", + expected: `{"model":"gpt-5.2"}`, + }, + { + name: "non-GPT model", + userID: 1, + model: "claude-sonnet-4-5", + expected: `{"model":"claude-sonnet-4-5"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &RelayInfo{ + UserId: tt.userID, + ChannelMeta: &ChannelMeta{ + ParamOverride: paramOverride, + UpstreamModelName: tt.model, + }, + } + input := []byte(fmt.Sprintf(`{"model":%q}`, tt.model)) + + out, err := ApplyParamOverrideWithRelayInfo(input, info) + + require.NoError(t, err) + require.JSONEq(t, tt.expected, string(out)) + }) + } +} + +func TestApplyParamOverrideConditionByGroupContext(t *testing.T) { + info := &RelayInfo{ + UserGroup: "vip", + TokenGroup: "premium", + UsingGroup: "priority-route", + } + ctx := BuildParamOverrideContext(info) + paramOverride := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "service_tier", + "mode": "set", + "value": "priority", + "logic": "AND", + "conditions": []interface{}{ + map[string]interface{}{ + "path": "user_group", + "mode": "full", + "value": "vip", + }, + map[string]interface{}{ + "path": "token_group", + "mode": "full", + "value": "premium", + }, + map[string]interface{}{ + "path": "using_group", + "mode": "full", + "value": "priority-route", + }, + }, + }, + }, + } + + out, err := ApplyParamOverride([]byte(`{"model":"gpt-5.2"}`), paramOverride, ctx) + + require.NoError(t, err) + require.JSONEq(t, `{"model":"gpt-5.2","service_tier":"priority"}`, string(out)) +} + func TestApplyParamOverrideConditionFromRequestHeaders(t *testing.T) { input := []byte(`{"temperature":0.7}`) override := map[string]interface{}{ From 8ad159a3bbc2da9f7432848a58c99bc2dafee227 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:49:26 +0800 Subject: [PATCH 15/18] fix(ollama): preserve reasoning and tool-call context (#6605) --- relay/channel/ollama/dto.go | 18 ++-- relay/channel/ollama/relay-ollama.go | 137 +++++++++++++++++++-------- relay/channel/ollama/stream.go | 6 +- relay/channel/ollama/stream_test.go | 13 ++- 4 files changed, 118 insertions(+), 56 deletions(-) diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go index 07aeb17a75c6..80e1ecfbc97a 100644 --- a/relay/channel/ollama/dto.go +++ b/relay/channel/ollama/dto.go @@ -5,12 +5,13 @@ import ( ) type OllamaChatMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - Images []string `json:"images,omitempty"` - ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` - ToolName string `json:"tool_name,omitempty"` - Thinking json.RawMessage `json:"thinking,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + Images []string `json:"images,omitempty"` + ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Thinking json.RawMessage `json:"thinking,omitempty"` } type OllamaToolFunction struct { @@ -25,6 +26,7 @@ type OllamaTool struct { } type OllamaToolCall struct { + ID string `json:"id,omitempty"` Function struct { Name string `json:"name"` Arguments interface{} `json:"arguments"` @@ -36,7 +38,7 @@ type OllamaChatRequest struct { Messages []OllamaChatMessage `json:"messages"` Tools interface{} `json:"tools,omitempty"` Format interface{} `json:"format,omitempty"` - Stream bool `json:"stream,omitempty"` + Stream bool `json:"stream"` Options map[string]any `json:"options,omitempty"` KeepAlive interface{} `json:"keep_alive,omitempty"` Think json.RawMessage `json:"think,omitempty"` @@ -48,7 +50,7 @@ type OllamaGenerateRequest struct { Suffix string `json:"suffix,omitempty"` Images []string `json:"images,omitempty"` Format interface{} `json:"format,omitempty"` - Stream bool `json:"stream,omitempty"` + Stream bool `json:"stream"` Options map[string]any `json:"options,omitempty"` KeepAlive interface{} `json:"keep_alive,omitempty"` Think json.RawMessage `json:"think,omitempty"` diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index 874d2e9b3240..e517a1e6aa03 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -1,7 +1,6 @@ package ollama import ( - "encoding/json" "fmt" "io" "net/http" @@ -19,24 +18,67 @@ import ( "github.com/samber/lo" ) +func toOllamaResponseFormat(responseFormat *dto.ResponseFormat) (any, error) { + if responseFormat == nil { + return nil, nil + } + switch responseFormat.Type { + case "json", "json_object": + return "json", nil + case "json_schema": + if len(responseFormat.JsonSchema) == 0 { + return nil, nil + } + var jsonSchema dto.FormatJsonSchema + if err := common.Unmarshal(responseFormat.JsonSchema, &jsonSchema); err != nil { + return nil, fmt.Errorf("invalid ollama response format: %w", err) + } + return jsonSchema.Schema, nil + default: + return nil, nil + } +} + func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) { + think := r.Think + if len(think) == 0 { + effort := r.ReasoningEffort + if len(r.Reasoning) > 0 { + var reasoning dto.Reasoning + if err := common.Unmarshal(r.Reasoning, &reasoning); err != nil { + return nil, fmt.Errorf("invalid ollama reasoning: %w", err) + } + effort = lo.CoalesceOrEmpty(reasoning.Effort, effort) + } + if effort != "" { + var thinkValue any + switch effort { + case "none": + thinkValue = false + case "low", "medium", "high", "max": + thinkValue = effort + default: + return nil, fmt.Errorf("unsupported ollama reasoning effort %q", effort) + } + var err error + think, err = common.Marshal(thinkValue) + if err != nil { + return nil, fmt.Errorf("marshal ollama think: %w", err) + } + } + } + chatReq := &OllamaChatRequest{ Model: r.Model, Stream: lo.FromPtrOr(r.Stream, false), Options: map[string]any{}, - Think: r.Think, + Think: think, } - if r.ResponseFormat != nil { - if r.ResponseFormat.Type == "json" { - chatReq.Format = "json" - } else if r.ResponseFormat.Type == "json_schema" { - if len(r.ResponseFormat.JsonSchema) > 0 { - var schema any - _ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema) - chatReq.Format = schema - } - } + format, err := toOllamaResponseFormat(r.ResponseFormat) + if err != nil { + return nil, err } + chatReq.Format = format // options mapping if r.Temperature != nil { @@ -68,12 +110,10 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam case []string: chatReq.Options["stop"] = v case []any: - arr := make([]string, 0, len(v)) - for _, i := range v { - if s, ok := i.(string); ok { - arr = append(arr, s) - } - } + arr := lo.FilterMap(v, func(item any, _ int) (string, bool) { + value, ok := item.(string) + return value, ok + }) if len(arr) > 0 { chatReq.Options["stop"] = arr } @@ -81,14 +121,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam } if len(r.Tools) > 0 { - tools := make([]OllamaTool, 0, len(r.Tools)) - for _, t := range r.Tools { - tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}}) - } - chatReq.Tools = tools + chatReq.Tools = lo.Map(r.Tools, func(tool dto.ToolCallRequest, _ int) OllamaTool { + return OllamaTool{ + Type: "function", + Function: OllamaToolFunction{ + Name: tool.Function.Name, + Description: tool.Function.Description, + Parameters: tool.Function.Parameters, + }, + } + }) } chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages)) + toolNamesByCallID := make(map[string]string) for _, m := range r.Messages { var textBuilder strings.Builder var images []string @@ -117,8 +163,18 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam if len(images) > 0 { cm.Images = images } - if m.Role == "tool" && m.Name != nil { - cm.ToolName = *m.Name + if m.Role == "assistant" { + if reasoning, ok := lo.Coalesce(m.ReasoningContent, m.Reasoning); ok { + thinking, err := common.Marshal(*reasoning) + if err != nil { + return nil, fmt.Errorf("marshal ollama thinking: %w", err) + } + cm.Thinking = thinking + } + } + if m.Role == "tool" { + cm.ToolCallID = m.ToolCallId + cm.ToolName = lo.CoalesceOrEmpty(lo.FromPtr(m.Name), toolNamesByCallID[m.ToolCallId]) } if m.ToolCalls != nil && len(m.ToolCalls) > 0 { parsed := m.ParseToolCalls() @@ -127,15 +183,18 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam for _, tc := range parsed { var args interface{} if tc.Function.Arguments != "" { - _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) + _ = common.Unmarshal([]byte(tc.Function.Arguments), &args) } if args == nil { args = map[string]any{} } - oc := OllamaToolCall{} + oc := OllamaToolCall{ID: tc.ID} oc.Function.Name = tc.Function.Name oc.Function.Arguments = args calls = append(calls, oc) + if tc.ID != "" { + toolNamesByCallID[tc.ID] = tc.Function.Name + } } cm.ToolCalls = calls } @@ -175,15 +234,11 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener gen.Suffix = s } } - if r.ResponseFormat != nil { - if r.ResponseFormat.Type == "json" { - gen.Format = "json" - } else if r.ResponseFormat.Type == "json_schema" { - var schema any - _ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema) - gen.Format = schema - } + format, err := toOllamaResponseFormat(r.ResponseFormat) + if err != nil { + return nil, err } + gen.Format = format if r.Temperature != nil { gen.Options["temperature"] = r.Temperature } @@ -212,12 +267,10 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener case []string: gen.Options["stop"] = v case []any: - arr := make([]string, 0, len(v)) - for _, i := range v { - if s, ok := i.(string); ok { - arr = append(arr, s) - } - } + arr := lo.FilterMap(v, func(item any, _ int) (string, bool) { + value, ok := item.(string) + return value, ok + }) if len(arr) > 0 { gen.Options["stop"] = arr } @@ -510,7 +563,7 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) { Version string `json:"version"` } - if err := json.Unmarshal(body, &versionResp); err != nil { + if err := common.Unmarshal(body, &versionResp); err != nil { return "", fmt.Errorf("解析响应失败: %v", err) } diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go index 20e36015ef20..a0d7839f9f6d 100644 --- a/relay/channel/ollama/stream.go +++ b/relay/channel/ollama/stream.go @@ -58,8 +58,12 @@ func ollamaToolCallsToOpenAI(toolCalls []OllamaToolCall, startIndex int, include argBytes = []byte("{}") } } + toolCallID := tc.ID + if toolCallID == "" { + toolCallID = fmt.Sprintf("call_%d", startIndex) + } tr := dto.ToolCallResponse{ - ID: fmt.Sprintf("call_%d", startIndex), + ID: toolCallID, Type: "function", Function: dto.FunctionResponse{ Name: tc.Function.Name, diff --git a/relay/channel/ollama/stream_test.go b/relay/channel/ollama/stream_test.go index 8ba58b191857..69aff1ce8f0f 100644 --- a/relay/channel/ollama/stream_test.go +++ b/relay/channel/ollama/stream_test.go @@ -21,12 +21,14 @@ func TestOllamaChatHandlerNonStreamToolCalls(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { - name string - raw string + name string + raw string + wantID string }{ { - name: "compact json per-line parse path", - raw: `{"model":"llama3.1","created_at":"2026-05-27T12:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_weather","arguments":{"city":"Paris","days":0}}}]},"done":true,"done_reason":"stop","prompt_eval_count":5,"eval_count":7}`, + name: "compact json per-line parse path", + raw: `{"model":"llama3.1","created_at":"2026-05-27T12:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_upstream","function":{"name":"get_weather","arguments":{"city":"Paris","days":0}}}]},"done":true,"done_reason":"stop","prompt_eval_count":5,"eval_count":7}`, + wantID: "call_upstream", }, { name: "pretty json fallback parse path", @@ -53,6 +55,7 @@ func TestOllamaChatHandlerNonStreamToolCalls(t *testing.T) { "prompt_eval_count": 5, "eval_count": 7 }`, + wantID: "call_0", }, } @@ -82,7 +85,7 @@ func TestOllamaChatHandlerNonStreamToolCalls(t *testing.T) { var toolCalls []dto.ToolCallResponse require.NoError(t, common.Unmarshal(out.Choices[0].Message.ToolCalls, &toolCalls)) require.Len(t, toolCalls, 1) - assert.NotEmpty(t, toolCalls[0].ID) + assert.Equal(t, tt.wantID, toolCalls[0].ID) assert.Equal(t, "function", toolCalls[0].Type) assert.Equal(t, "get_weather", toolCalls[0].Function.Name) assert.Nil(t, toolCalls[0].Index) From d49160f0e5433a2b87e1431c0b7bf01d8e429e75 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:50:08 +0800 Subject: [PATCH 16/18] fix: backend length validation (#5548) --- setting/console_setting/validation.go | 36 ++++++++++++++++----------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go index d6e4342c3d8f..5c664a1feadd 100644 --- a/setting/console_setting/validation.go +++ b/setting/console_setting/validation.go @@ -1,13 +1,15 @@ package console_setting import ( - "encoding/json" "fmt" "net/url" "regexp" "sort" "strings" "time" + "unicode/utf16" + + "github.com/QuantumNous/new-api/common" ) var ( @@ -24,12 +26,16 @@ var ( func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) { var list []map[string]interface{} - if err := json.Unmarshal([]byte(jsonStr), &list); err != nil { + if err := common.UnmarshalJsonStr(jsonStr, &list); err != nil { return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error()) } return list, nil } +func exceedsMaxCharacters(s string, max int) bool { + return len(utf16.Encode([]rune(s))) > max +} + func validateURL(urlStr string, index int, itemType string) error { if !urlRegex.MatchString(urlStr) { return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType) @@ -55,7 +61,7 @@ func getJSONList(jsonStr string) []map[string]interface{} { return []map[string]interface{}{} } var list []map[string]interface{} - json.Unmarshal([]byte(jsonStr), &list) + _ = common.UnmarshalJsonStr(jsonStr, &list) return list } @@ -110,13 +116,13 @@ func validateApiInfo(apiInfoStr string) error { return err } - if len(urlStr) > 500 { + if exceedsMaxCharacters(urlStr, 500) { return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) } - if len(route) > 100 { + if exceedsMaxCharacters(route, 100) { return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) } - if len(description) > 200 { + if exceedsMaxCharacters(description, 200) { return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) } @@ -172,12 +178,12 @@ func validateAnnouncements(announcementsStr string) error { } } } - if len(content) > 500 { + if exceedsMaxCharacters(content, 500) { return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1) } if extra, exists := ann["extra"]; exists { - if extraStr, ok := extra.(string); ok && len(extraStr) > 200 { - return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1) + if extraStr, ok := extra.(string); ok && exceedsMaxCharacters(extraStr, 100) { + return fmt.Errorf("第%d个公告的说明长度不能超过100字符", i+1) } } } @@ -201,10 +207,10 @@ func validateFAQ(faqStr string) error { if !ok || answer == "" { return fmt.Errorf("第%d个FAQ缺少答案字段", i+1) } - if len(question) > 200 { + if exceedsMaxCharacters(question, 200) { return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1) } - if len(answer) > 1000 { + if exceedsMaxCharacters(answer, 1000) { return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1) } } @@ -272,16 +278,16 @@ func validateUptimeKumaGroups(groupsStr string) error { return err } - if len(categoryName) > 50 { + if exceedsMaxCharacters(categoryName, 50) { return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1) } - if len(urlStr) > 500 { + if exceedsMaxCharacters(urlStr, 500) { return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1) } - if len(slug) > 100 { + if exceedsMaxCharacters(slug, 100) { return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1) } - if len(description) > 200 { + if exceedsMaxCharacters(description, 200) { return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1) } From 4cf9107f043709b3364a48f7a7bacc5f8ca80928 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:50:27 +0800 Subject: [PATCH 17/18] feat(billing): highlight matched conditional multipliers in logs (#6561) * feat(billing): highlight matched conditional multipliers in usage logs * fix(billing): make request rule tracing stable and type-safe --- controller/channel_test_internal_test.go | 9 +- pkg/billingexpr/billingexpr_test.go | 73 +++++++- pkg/billingexpr/compile.go | 171 ++++++++++++++---- pkg/billingexpr/expr.md | 30 ++- pkg/billingexpr/run.go | 32 +++- pkg/billingexpr/settle.go | 1 + pkg/billingexpr/types.go | 25 ++- service/log_info_generate.go | 3 + .../components/dynamic-pricing-breakdown.tsx | 86 ++++++--- web/src/features/pricing/lib/billing-expr.ts | 72 +++++--- .../components/dialogs/details-dialog.tsx | 1 + web/src/features/usage-logs/types.ts | 7 +- 12 files changed, 400 insertions(+), 110 deletions(-) diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index fa69f852ae2d..aa1a6ca7843a 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -265,12 +265,19 @@ func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) { }, } + requestRules := []billingexpr.RequestRuleTrace{{ + Cond: `param("service_tier") == "fast"`, + Multiplier: 2, + Matched: true, + }} other := buildTestLogOther(ctx, info, priceData, usage, &billingexpr.TieredResult{ - MatchedTier: "base", + MatchedTier: "base", + RequestRules: requestRules, }) require.Equal(t, "tiered_expr", other["billing_mode"]) require.Equal(t, "base", other["matched_tier"]) + require.Equal(t, requestRules, other["request_rules"]) require.NotEmpty(t, other["expr_b64"]) } diff --git a/pkg/billingexpr/billingexpr_test.go b/pkg/billingexpr/billingexpr_test.go index 7b59ed258681..90485571a5fb 100644 --- a/pkg/billingexpr/billingexpr_test.go +++ b/pkg/billingexpr/billingexpr_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/QuantumNous/new-api/pkg/billingexpr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- @@ -228,10 +230,11 @@ func TestRequestProbeMissingFieldReturnsNil(t *testing.T) { } } -func TestRequestProbeMultipleRulesMultiply(t *testing.T) { - cost, _, err := billingexpr.RunExprWithRequest( - `(param("service_tier") == "fast" ? 2 : 1) * (has(header("anthropic-beta"), "fast-mode-2026-02-01") ? 2.5 : 1)`, - billingexpr.TokenParams{}, +func TestRequestProbeMultipleRulesTraceAllFactors(t *testing.T) { + exprStr := `(tier("base", p * 2)) * (param("service_tier") == "fast" ? 2 : 1) * (has(header("anthropic-beta"), "fast-mode-2026-02-01") ? 2.5 : 1)` + cost, trace, err := billingexpr.RunExprWithRequest( + exprStr, + billingexpr.TokenParams{P: 10}, billingexpr.RequestInput{ Headers: map[string]string{ "Anthropic-Beta": "fast-mode-2026-02-01", @@ -239,12 +242,62 @@ func TestRequestProbeMultipleRulesMultiply(t *testing.T) { Body: []byte(`{"service_tier":"fast"}`), }, ) - if err != nil { - t.Fatal(err) - } - if math.Abs(cost-5) > 1e-6 { - t.Errorf("cost = %f, want 5", cost) - } + + require.NoError(t, err) + assert.InDelta(t, 100, cost, 1e-6) + assert.Equal(t, "base", trace.MatchedTier) + assert.Equal(t, []billingexpr.RequestRuleTrace{ + {Cond: `param("service_tier") == "fast"`, Multiplier: 2, Matched: true}, + {Cond: `has(header("anthropic-beta"), "fast-mode-2026-02-01")`, Multiplier: 2.5, Matched: true}, + }, trace.RequestRules) +} + +func TestRequestProbeTraceIncludesUnmatchedFactors(t *testing.T) { + exprStr := `(tier("base", p * 2)) * (param("service_tier") == "fast" ? 2 : 1) * (has(header("anthropic-beta"), "fast-mode") ? 2.5 : 1)` + cost, trace, err := billingexpr.RunExprWithRequest( + exprStr, + billingexpr.TokenParams{P: 10}, + billingexpr.RequestInput{Body: []byte(`{"service_tier":"fast"}`)}, + ) + + require.NoError(t, err) + assert.InDelta(t, 40, cost, 1e-6) + assert.Equal(t, []billingexpr.RequestRuleTrace{ + {Cond: `param("service_tier") == "fast"`, Multiplier: 2, Matched: true}, + {Cond: `has(header("anthropic-beta"), "fast-mode")`, Multiplier: 2.5, Matched: false}, + }, trace.RequestRules) +} + +func TestRequestProbeTracePreservesIntegerConditionalType(t *testing.T) { + cost, trace, err := billingexpr.RunExprWithRequest( + `5 % (param("service_tier") == "fast" ? 2 : 1)`, + billingexpr.TokenParams{}, + billingexpr.RequestInput{Body: []byte(`{"service_tier":"fast"}`)}, + ) + + require.NoError(t, err) + assert.Equal(t, float64(1), cost) + assert.Equal(t, []billingexpr.RequestRuleTrace{ + {Cond: `param("service_tier") == "fast"`, Multiplier: 2, Matched: true}, + }, trace.RequestRules) +} + +func TestRequestProbeNonUnitFallbackIsNotTraced(t *testing.T) { + cost, trace, err := billingexpr.RunExprWithRequest( + `10 * (param("service_tier") == "fast" ? 2 : 1.5)`, + billingexpr.TokenParams{}, + billingexpr.RequestInput{Body: []byte(`{"service_tier":"standard"}`)}, + ) + + require.NoError(t, err) + assert.InDelta(t, 15, cost, 1e-6) + assert.Empty(t, trace.RequestRules) +} + +func TestRequestProbeInternalTraceFunctionIsReserved(t *testing.T) { + _, err := billingexpr.CompileFromCache(`_trace(0, true, 5.0)`) + + require.ErrorContains(t, err, `identifier "_trace" is reserved for internal use`) } func TestCeilFloor(t *testing.T) { diff --git a/pkg/billingexpr/compile.go b/pkg/billingexpr/compile.go index a6c7b8f7221a..72a22189f6d9 100644 --- a/pkg/billingexpr/compile.go +++ b/pkg/billingexpr/compile.go @@ -16,6 +16,11 @@ const maxCacheSize = 256 // DefaultExprVersion is used when an expression string has no version prefix. const DefaultExprVersion = 1 +const ( + requestRuleTraceFunction = "_trace" + requestRuleTraceIntFunction = "_trace_int" +) + // ParseExprVersion extracts the version tag and body from an expression string. // Format: "v1:tier(...)" → version=1, body="tier(...)". // No prefix defaults to DefaultExprVersion. @@ -26,10 +31,88 @@ func ParseExprVersion(exprStr string) (version int, body string) { return DefaultExprVersion, exprStr } +// requestRulePatcher adds trace side effects to existing request multipliers +// without changing the stored expression or its numeric result. +type requestRulePatcher struct { + requestRules []RequestRuleTrace + restrictedIdentifier string +} + +func (p *requestRulePatcher) Visit(node *ast.Node) { + if identifier, ok := (*node).(*ast.IdentifierNode); ok { + switch identifier.Value { + case requestRuleTraceFunction, requestRuleTraceIntFunction: + p.restrictedIdentifier = identifier.Value + } + return + } + + conditional, ok := (*node).(*ast.ConditionalNode) + if !ok || !conditional.Ternary || !usesRequestProbe(conditional.Cond) { + return + } + multiplier, ok := requestRuleNumber(conditional.Exp1) + fallback, fallbackOK := requestRuleNumber(conditional.Exp2) + if !ok || !fallbackOK || fallback != 1 { + return + } + + ruleIndex := len(p.requestRules) + p.requestRules = append(p.requestRules, RequestRuleTrace{ + Cond: conditional.Cond.String(), + Multiplier: multiplier, + }) + + traceFunction := requestRuleTraceFunction + var multiplierNode ast.Node = &ast.FloatNode{Value: multiplier} + if _, multiplierIsInt := conditional.Exp1.(*ast.IntegerNode); multiplierIsInt { + if _, fallbackIsInt := conditional.Exp2.(*ast.IntegerNode); fallbackIsInt { + traceFunction = requestRuleTraceIntFunction + multiplierNode = conditional.Exp1 + } + } + + ast.Patch(node, &ast.CallNode{ + Callee: &ast.IdentifierNode{Value: traceFunction}, + Arguments: []ast.Node{ + &ast.IntegerNode{Value: ruleIndex}, + conditional.Cond, + multiplierNode, + }, + }) +} + +func requestRuleNumber(node ast.Node) (float64, bool) { + switch value := node.(type) { + case *ast.IntegerNode: + return float64(value.Value), true + case *ast.FloatNode: + return value.Value, true + default: + return 0, false + } +} + +func usesRequestProbe(node ast.Node) bool { + return ast.Find(node, func(node ast.Node) bool { + identifier, ok := node.(*ast.IdentifierNode) + if !ok { + return false + } + switch identifier.Value { + case "param", "header", "hour", "minute", "weekday", "month", "day": + return true + default: + return false + } + }) != nil +} + type cachedEntry struct { - prog *vm.Program - usedVars map[string]bool - version int + prog *vm.Program + usedVars map[string]bool + requestRules []RequestRuleTrace + version int } var ( @@ -39,30 +122,32 @@ var ( // compileEnvPrototypeV1 is the v1 type-checking prototype used at compile time. var compileEnvPrototypeV1 = map[string]interface{}{ - "p": float64(0), - "c": float64(0), - "len": float64(0), - "cr": float64(0), - "cc": float64(0), - "cc1h": float64(0), - "img": float64(0), - "img_o": float64(0), - "ai": float64(0), - "ao": float64(0), - "tier": func(string, float64) float64 { return 0 }, - "header": func(string) string { return "" }, - "param": func(string) interface{} { return nil }, - "has": func(interface{}, string) bool { return false }, - "hour": func(string) int { return 0 }, - "minute": func(string) int { return 0 }, - "weekday": func(string) int { return 0 }, - "month": func(string) int { return 0 }, - "day": func(string) int { return 0 }, - "max": math.Max, - "min": math.Min, - "abs": math.Abs, - "ceil": math.Ceil, - "floor": math.Floor, + "p": float64(0), + "c": float64(0), + "len": float64(0), + "cr": float64(0), + "cc": float64(0), + "cc1h": float64(0), + "img": float64(0), + "img_o": float64(0), + "ai": float64(0), + "ao": float64(0), + "tier": func(string, float64) float64 { return 0 }, + "_trace": func(int, bool, float64) float64 { return 1 }, + "_trace_int": func(int, bool, int) int { return 1 }, + "header": func(string) string { return "" }, + "param": func(string) interface{} { return nil }, + "has": func(interface{}, string) bool { return false }, + "hour": func(string) int { return 0 }, + "minute": func(string) int { return 0 }, + "weekday": func(string) int { return 0 }, + "month": func(string) int { return 0 }, + "day": func(string) int { return 0 }, + "max": math.Max, + "min": math.Min, + "abs": math.Abs, + "ceil": math.Ceil, + "floor": math.Floor, } func getCompileEnv(version int) map[string]interface{} { @@ -85,29 +170,45 @@ func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) { } func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) { + entry, err := compileEntryFromCacheByHash(exprStr, hash) + if err != nil { + return nil, err + } + return entry.prog, nil +} + +func compileEntryFromCacheByHash(exprStr, hash string) (*cachedEntry, error) { cacheMu.RLock() if entry, ok := cache[hash]; ok { cacheMu.RUnlock() - return entry.prog, nil + return entry, nil } cacheMu.RUnlock() version, body := ParseExprVersion(exprStr) - prog, err := expr.Compile(body, expr.Env(getCompileEnv(version)), expr.AsFloat64()) + patcher := &requestRulePatcher{} + prog, err := expr.Compile(body, expr.Env(getCompileEnv(version)), expr.Patch(patcher), expr.AsFloat64()) + if patcher.restrictedIdentifier != "" { + return nil, fmt.Errorf("expr compile error: identifier %q is reserved for internal use", patcher.restrictedIdentifier) + } if err != nil { return nil, fmt.Errorf("expr compile error: %w", err) } - vars := extractUsedVars(prog) - + entry := &cachedEntry{ + prog: prog, + usedVars: extractUsedVars(prog), + requestRules: patcher.requestRules, + version: version, + } cacheMu.Lock() if len(cache) >= maxCacheSize { cache = make(map[string]*cachedEntry, 64) } - cache[hash] = &cachedEntry{prog: prog, usedVars: vars, version: version} + cache[hash] = entry cacheMu.Unlock() - return prog, nil + return entry, nil } // ExprVersion returns the version of a cached expression. Returns DefaultExprVersion @@ -132,6 +233,10 @@ func extractUsedVars(prog *vm.Program) map[string]bool { node := prog.Node() ast.Find(node, func(n ast.Node) bool { if id, ok := n.(*ast.IdentifierNode); ok { + switch id.Value { + case requestRuleTraceFunction, requestRuleTraceIntFunction: + return false + } vars[id.Value] = true } return false diff --git a/pkg/billingexpr/expr.md b/pkg/billingexpr/expr.md index c52f2e614e5e..f192c74ee6a6 100644 --- a/pkg/billingexpr/expr.md +++ b/pkg/billingexpr/expr.md @@ -116,7 +116,31 @@ Request-conditional multipliers are appended to the expression after a `|||` sep tier("base", p * 5 + c * 25)|||when(header("anthropic-beta") has "fast-mode") * 6 ``` -These are parsed and applied separately by the request rule system. +These factors are stored as ordinary multiplication in the final expression (for example, `(tier(...)) * (condition ? 6 : 1)`) and run in the same billing program. + +### Request Rule Tracing + +At compile time, the engine instruments ternary factors with this exact shape: + +``` + ? : 1 +``` + +The condition must reference at least one request probe (`param`, `header`, `hour`, `minute`, `weekday`, `month`, or `day`). Both branches must be numeric literals and the fallback must equal `1`. Other conditionals, including `(condition ? 2 : 1.5)`, are evaluated normally but are not traced. Integer-only factors use an integer-preserving trace callback, so instrumentation does not change expressions that require an integer operand (for example, `%`). The internal trace callback names are reserved and cannot be used in stored expressions. + +The compiled cache stores the canonical condition and multiplier for every instrumented node. Each run starts with the full detected rule list marked as unmatched; callbacks mark rules that actually evaluate true. Rules skipped by normal expression short-circuiting remain unmatched. This keeps the expression's numeric result unchanged and avoids reparsing it on each request. + +Settlement copies the actual run's traces into the consume log as: + +```json +{ + "request_rules": [ + { "cond": "param(\"service_tier\") == \"fast\"", "multiplier": 2, "matched": true } + ] +} +``` + +The usage-log UI treats `request_rules` as the authoritative rule list and renders directly from it. It parses `cond` only to produce a friendly label and falls back to the canonical condition text when that parser does not recognize the condition. Pricing pages without log context continue to parse the stored expression for display. --- @@ -182,9 +206,9 @@ After the upstream response returns with actual token usage: **Files**: `service/log_info_generate.go`, `web/src/helpers/render.jsx` -Backend: `InjectTieredBillingInfo()` adds `billing_mode`, `expr_b64` (base64 expression), and `matched_tier` to the log's `other` JSON. +Backend: `InjectTieredBillingInfo()` adds `billing_mode`, `expr_b64` (base64 expression), `matched_tier`, and the structured `request_rules` trace list to the log's `other` JSON. -Frontend: Detects `billing_mode === "tiered_expr"`, decodes `expr_b64`, parses tiers via shared `parseTiersFromExpr()`, and renders pricing breakdown. +Frontend: Detects `billing_mode === "tiered_expr"`, decodes `expr_b64`, parses tiers via shared `parseTiersFromExpr()`, and renders request multipliers from `request_rules` when present. Without log traces, it falls back to parsing the stored expression. --- diff --git a/pkg/billingexpr/run.go b/pkg/billingexpr/run.go index 7c0f2ecdd803..397099d557d8 100644 --- a/pkg/billingexpr/run.go +++ b/pkg/billingexpr/run.go @@ -26,11 +26,11 @@ func RunExpr(exprStr string, params TokenParams) (float64, TraceResult, error) { } func RunExprWithRequest(exprStr string, params TokenParams, request RequestInput) (float64, TraceResult, error) { - prog, err := CompileFromCache(exprStr) + entry, err := compileEntryFromCacheByHash(exprStr, ExprHashString(exprStr)) if err != nil { return 0, TraceResult{}, err } - return runProgram(prog, params, request) + return runProgram(entry.prog, entry.requestRules, params, request) } // RunExprByHash is like RunExpr but accepts a pre-computed hash for the cache @@ -41,15 +41,17 @@ func RunExprByHash(exprStr, hash string, params TokenParams) (float64, TraceResu } func RunExprByHashWithRequest(exprStr, hash string, params TokenParams, request RequestInput) (float64, TraceResult, error) { - prog, err := CompileFromCacheByHash(exprStr, hash) + entry, err := compileEntryFromCacheByHash(exprStr, hash) if err != nil { return 0, TraceResult{}, err } - return runProgram(prog, params, request) + return runProgram(entry.prog, entry.requestRules, params, request) } -func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (float64, TraceResult, error) { - trace := TraceResult{} +func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, params TokenParams, request RequestInput) (float64, TraceResult, error) { + trace := TraceResult{ + RequestRules: append([]RequestRuleTrace(nil), requestRules...), + } headers := normalizeHeaders(request.Headers) env := map[string]interface{}{ @@ -68,6 +70,24 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo trace.Cost = value return value }, + requestRuleTraceFunction: func(ruleIndex int, matched bool, multiplier float64) float64 { + if matched && ruleIndex >= 0 && ruleIndex < len(trace.RequestRules) { + trace.RequestRules[ruleIndex].Matched = true + } + if matched { + return multiplier + } + return 1 + }, + requestRuleTraceIntFunction: func(ruleIndex int, matched bool, multiplier int) int { + if matched && ruleIndex >= 0 && ruleIndex < len(trace.RequestRules) { + trace.RequestRules[ruleIndex].Matched = true + } + if matched { + return multiplier + } + return 1 + }, "header": func(key string) string { return headers[strings.ToLower(strings.TrimSpace(key))] }, diff --git a/pkg/billingexpr/settle.go b/pkg/billingexpr/settle.go index f8cf937ae34b..a1c3267686f9 100644 --- a/pkg/billingexpr/settle.go +++ b/pkg/billingexpr/settle.go @@ -32,6 +32,7 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re ActualQuotaBeforeGroup: quotaBeforeGroup, ActualQuotaAfterGroup: afterGroup, MatchedTier: trace.MatchedTier, + RequestRules: trace.RequestRules, CrossedTier: crossed, Clamp: clamp, }, nil diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go index e95399b4fed0..1bdf6834bd57 100644 --- a/pkg/billingexpr/types.go +++ b/pkg/billingexpr/types.go @@ -28,12 +28,18 @@ type TokenParams struct { AO float64 // audio output tokens } -// TraceResult holds side-channel info captured by the tier() function -// during Expr execution. This replaces the old Breakdown mechanism — -// the Expr itself is the single source of truth for billing logic. +// RequestRuleTrace describes one request-dependent multiplier detected at compile time. +type RequestRuleTrace struct { + Cond string `json:"cond"` + Multiplier float64 `json:"multiplier"` + Matched bool `json:"matched"` +} + +// TraceResult holds side-channel info captured while an expression runs. type TraceResult struct { - MatchedTier string `json:"matched_tier"` - Cost float64 `json:"cost"` + MatchedTier string `json:"matched_tier"` + RequestRules []RequestRuleTrace `json:"request_rules,omitempty"` + Cost float64 `json:"cost"` } // BillingSnapshot captures billing state at pre-consume time. Expression and @@ -57,10 +63,11 @@ type BillingSnapshot struct { // TieredResult holds everything needed after running tiered settlement. type TieredResult struct { - ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"` - ActualQuotaAfterGroup int `json:"actual_quota_after_group"` - MatchedTier string `json:"matched_tier"` - CrossedTier bool `json:"crossed_tier"` + ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"` + ActualQuotaAfterGroup int `json:"actual_quota_after_group"` + MatchedTier string `json:"matched_tier"` + RequestRules []RequestRuleTrace `json:"request_rules,omitempty"` + CrossedTier bool `json:"crossed_tier"` // Clamp records an int32 saturation event during quota conversion so the // caller can surface it on the consume log for admin auditing. Nil when no // clamping occurred. Not serialized: the marker is attached separately via diff --git a/service/log_info_generate.go b/service/log_info_generate.go index e510ec02606c..353f7098f7a1 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -316,5 +316,8 @@ func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommo other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString)) if result != nil { other["matched_tier"] = result.MatchedTier + if len(result.RequestRules) > 0 { + other["request_rules"] = result.RequestRules + } } } diff --git a/web/src/features/pricing/components/dynamic-pricing-breakdown.tsx b/web/src/features/pricing/components/dynamic-pricing-breakdown.tsx index 79be84ddea70..848454cc3ec6 100644 --- a/web/src/features/pricing/components/dynamic-pricing-breakdown.tsx +++ b/web/src/features/pricing/components/dynamic-pricing-breakdown.tsx @@ -36,11 +36,13 @@ import { SOURCE_TIME, normalizeTierLabel, parseTiersFromExpr, + requestRuleGroupsFromTrace, splitBillingExprAndRequestRules, tryParseRequestRuleExpr, type ParsedTier, type RequestCondition, type RequestRuleGroup, + type RequestRuleTrace, type TierCondition, } from '../lib/billing-expr' @@ -52,6 +54,8 @@ type DynamicPricingBreakdownProps = { * the usage-log details dialog to show which tier the engine selected. */ matchedTierLabel?: string | null + /** Request-rule traces emitted by the settlement run. */ + requestRules?: RequestRuleTrace[] | null /** * Hide cache-pricing columns regardless of the per-tier values. The log * details dialog passes this when the actual request did not consume any @@ -148,14 +152,25 @@ function describeGroup( group: RequestRuleGroup, t: (key: string) => string ): string { - return (group.conditions || []) - .map((c) => describeCondition(c, t)) + const description = (group.conditions || []) + .map((condition) => describeCondition(condition, t)) .join(' && ') + return description || group.conditionText || '' +} + +function nextOccurrenceKey( + baseKey: string, + occurrences: Map +): string { + const occurrence = occurrences.get(baseKey) || 0 + occurrences.set(baseKey, occurrence + 1) + return `${baseKey}:${occurrence}` } export function DynamicPricingBreakdown({ billingExpr, matchedTierLabel, + requestRules, hideCacheColumns = false, compact = false, }: DynamicPricingBreakdownProps) { @@ -179,12 +194,15 @@ export function DynamicPricingBreakdown({ const { tiers, ruleGroups } = useMemo(() => { const split = splitBillingExprAndRequestRules(expr) const parsedTiers = parseTiersFromExpr(split.billingExpr) - const parsedRules = tryParseRequestRuleExpr(split.requestRuleExpr || '') + const parsedRules = + requestRules != null + ? requestRuleGroupsFromTrace(requestRules) + : tryParseRequestRuleExpr(split.requestRuleExpr || '') return { tiers: parsedTiers, ruleGroups: parsedRules || [], } - }, [expr]) + }, [expr, requestRules]) const hasTiers = tiers.length > 0 const hasRules = ruleGroups.length > 0 @@ -229,6 +247,8 @@ export function DynamicPricingBreakdown({ (tier) => Number(tier[v.field as string as keyof ParsedTier] || 0) > 0 ) }) + const mobileTierKeyOccurrences = new Map() + const requestRuleKeyOccurrences = new Map() return (
@@ -260,15 +280,19 @@ export function DynamicPricingBreakdown({ {t('Tiered price table')}
- {tiers.map((tier, i) => { + {tiers.map((tier) => { const condSummary = formatConditionSummary(tier.conditions, t) const isMatched = matchedTierLabel != null && matchedTierLabel !== '' && tier.label === matchedTierLabel + const rowKey = nextOccurrenceKey( + JSON.stringify(tier), + mobileTierKeyOccurrences + ) return (
    - {ruleGroups.map((group, gi) => ( -
  • - { + const isMatched = group.matched === true + const rowKey = nextOccurrenceKey( + `${group.conditionText || JSON.stringify(group.conditions)}:${group.multiplier}`, + requestRuleKeyOccurrences + ) + return ( +
  • - {describeGroup(group, t)} - - - {group.multiplier}x - -
  • - ))} + + {describeGroup(group, t)} + + + {group.multiplier}x{isMatched && ` · ${t('Matched')}`} + + + ) + })}
)} diff --git a/web/src/features/pricing/lib/billing-expr.ts b/web/src/features/pricing/lib/billing-expr.ts index 59f64b7a3b99..f1428f674904 100644 --- a/web/src/features/pricing/lib/billing-expr.ts +++ b/web/src/features/pricing/lib/billing-expr.ts @@ -226,6 +226,14 @@ export type RequestCondition = TimeCondition | ParamHeaderCondition export type RequestRuleGroup = { conditions: RequestCondition[] multiplier: string + conditionText?: string + matched?: boolean +} + +export type RequestRuleTrace = { + cond: string + multiplier: number + matched: boolean } export type TierCondition = { @@ -307,9 +315,9 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] { export function normalizeTierLabel(label: string | undefined): string { if (!label) return '' return label - .replace(/<[==]?|≤|<[==]?/g, '<') - .replace(/>[==]?|≥|>[==]?/g, '>') - .replace(/\s+/g, '') + .replaceAll(/<[==]?|≤|<[==]?/g, '<') + .replaceAll(/>[==]?|≥|>[==]?/g, '>') + .replaceAll(/\s+/g, '') .toLowerCase() } @@ -426,24 +434,26 @@ function tryParseRequestCondition(expr: string): RequestCondition | null { if (m) return { source: 'param', path: m[1], mode: MATCH_EXISTS, value: '' } m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/) - if (m) + if (m) { return { source: 'header', path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[2]) as string, } + } m = expr.match( /^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/ ) - if (m && m[1] === m[2]) + if (m && m[1] === m[2]) { return { source: 'param', path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[3]) as string, } + } m = expr.match( /^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/ @@ -473,22 +483,40 @@ function tryParseRequestCondition(expr: string): RequestCondition | null { return null } +function tryParseRequestConditions( + conditionStr: string +): RequestCondition[] | null { + const andParts = splitTopLevelAnd(conditionStr) + const conditions: RequestCondition[] = [] + for (const part of andParts) { + const condition = tryParseRequestCondition(part.trim()) + if (!condition) return null + conditions.push(condition) + } + return conditions.length > 0 ? conditions : null +} + function tryParseRuleGroupFactor(part: string): RequestRuleGroup | null { const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s) if (!m) return null - const conditionStr = m[1] - const multiplier = m[2] + const conditions = tryParseRequestConditions(m[1]) + if (!conditions) return null + return { conditions, multiplier: m[2] } +} - const andParts = splitTopLevelAnd(conditionStr) - const conditions: RequestCondition[] = [] - for (const ap of andParts) { - const cond = tryParseRequestCondition(ap.trim()) - if (!cond) return null - conditions.push(cond) - } - if (conditions.length === 0) return null - return { conditions, multiplier } +export function requestRuleGroupsFromTrace( + requestRules: RequestRuleTrace[] +): RequestRuleGroup[] { + return requestRules.map((rule) => { + const conditionText = rule.cond.trim() + return { + conditions: tryParseRequestConditions(conditionText) || [], + multiplier: String(rule.multiplier), + conditionText, + matched: rule.matched, + } + }) } export function tryParseRequestRuleExpr( @@ -642,12 +670,12 @@ function isTimeFunc(value: unknown): value is TimeFunc { export function normalizeCondition( cond: Partial | null | undefined ): RequestCondition { - const source = - cond?.source === 'time' - ? 'time' - : cond?.source === 'header' - ? 'header' - : 'param' + let source: RequestCondition['source'] = 'param' + if (cond?.source === 'time') { + source = 'time' + } else if (cond?.source === 'header') { + source = 'header' + } if (source === 'time') { const timeCond = cond as Partial | null | undefined diff --git a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx index 0c8478f41380..f30e35a86555 100644 --- a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -1078,6 +1078,7 @@ export function DetailsDialog(props: DetailsDialogProps) { compact billingExpr={decodeBillingExprB64(other.expr_b64)} matchedTierLabel={other.matched_tier} + requestRules={other.request_rules} hideCacheColumns={!hasAnyCacheTokens(other)} /> diff --git a/web/src/features/usage-logs/types.ts b/web/src/features/usage-logs/types.ts index 2cd43e13761c..3e3789b9cbf3 100644 --- a/web/src/features/usage-logs/types.ts +++ b/web/src/features/usage-logs/types.ts @@ -19,8 +19,9 @@ For commercial licensing, please contact support@quantumnous.com /** * Type definitions for usage logs */ -import type { UsageLog } from './data/schema' +import type { RequestRuleTrace } from '@/features/pricing/lib/billing-expr' +import type { UsageLog } from './data/schema' // ============================================================================ // Log Category Types // ============================================================================ @@ -189,10 +190,12 @@ export interface LogOtherData { frt?: number // Tiered (expression-based) billing fields, set by backend when // billing_mode === 'tiered_expr'. expr_b64 is the base64-encoded billing - // expression and matched_tier is the label of the tier that fired. + // expression; the matched tier and request-rule traces come from the actual + // settlement run. billing_mode?: string expr_b64?: string matched_tier?: string + request_rules?: RequestRuleTrace[] reasoning_effort?: string image?: boolean image_ratio?: number From 9c97e78aced572d540f227007a675d7d007666ac Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:12:59 +0800 Subject: [PATCH 18/18] fix(web): require confirmation before rotating access token (#6749) --- .../dialogs/access-token-dialog.tsx | 216 ++++++++++++------ .../profile/hooks/use-access-token.ts | 5 + web/src/i18n/locales/en.json | 8 + web/src/i18n/locales/fr.json | 8 + web/src/i18n/locales/ja.json | 8 + web/src/i18n/locales/ru.json | 8 + web/src/i18n/locales/vi.json | 8 + web/src/i18n/locales/zh-TW.json | 8 + web/src/i18n/locales/zh.json | 8 + 9 files changed, 209 insertions(+), 68 deletions(-) diff --git a/web/src/features/profile/components/dialogs/access-token-dialog.tsx b/web/src/features/profile/components/dialogs/access-token-dialog.tsx index 550166caa158..c506cc95f1fb 100644 --- a/web/src/features/profile/components/dialogs/access-token-dialog.tsx +++ b/web/src/features/profile/components/dialogs/access-token-dialog.tsx @@ -16,13 +16,21 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { RefreshCw, Loader2 } from 'lucide-react' -import { useEffect } from 'react' +import { KeyRound, Loader2, RefreshCw } from 'lucide-react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { ConfirmDialog } from '@/components/confirm-dialog' import { CopyButton } from '@/components/copy-button' import { Dialog } from '@/components/dialog' import { Button } from '@/components/ui/button' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -42,77 +50,149 @@ export function AccessTokenDialog({ onOpenChange, }: AccessTokenDialogProps) { const { t } = useTranslation() - const { token, generating, generate } = useAccessToken() + const { token, generating, generate, clearToken } = useAccessToken() + const [confirmOpen, setConfirmOpen] = useState(false) - // Auto-generate token when dialog opens if no token exists - useEffect(() => { - if (open && !token) { - generate() + const handleOpenChange = (nextOpen: boolean) => { + if (generating) return + + if (!nextOpen) { + setConfirmOpen(false) + clearToken() + } + onOpenChange(nextOpen) + } + + const handleGenerate = async () => { + if (await generate()) { + setConfirmOpen(false) } - }, [open, token, generate]) + } return ( - - - - - } - > -
-
- -
- - + +
-

- {t('Use this token for API authentication')} -

+ onClick={() => handleOpenChange(false)} + disabled={generating} + > + {t('Close')} + + + + } + > +
+ {token ? ( +
+ +
+ + +
+

+ {t( + "Save this token now. You won't be able to view it again after closing this dialog." + )} +

+
+ ) : ( + + + + + + {t('Access tokens are shown only once')} + + + {t( + 'For security, existing access tokens cannot be displayed. Regenerate only when you need a new token.' + )} + + + {t( + 'Regenerating immediately invalidates any existing token.' + )} + + + + )}
-
-
+
+ + { + if (!generating) setConfirmOpen(nextOpen) + }} + title={t('Regenerate access token?')} + desc={ +
+

+ {t( + 'This will immediately invalidate your existing access token. Any applications or scripts using it will stop working.' + )} +

+

+ {t( + 'The new token will only be shown once. Copy it and store it securely.' + )} +

+
+ } + confirmText={ + generating ? ( + <> +